STV2022 – Store tekstdata

Solveig Bjørkholt og Martin Søyland

2022-10-14

1 Introduksjon

Velkommen til STV2022 – Store teksdata!

Dette er en arbeidsbok som går gjennom de forskjellige delene i kurset STV2022 – Store teksdata, med tilhørende R-kode. Meningen med arbeidsboken, er at den kan brukes som forslag til implementering av metoder i semesteroppgaven. Merk likevel at dette ikke er en fasit!

Om du skulle finne feil i dokumentet, legg gjerne inn en issue på github så får vi fikset det i en fei.

Siste endring:

## startet såvidt på forelesning 08 om emnemodellering (2022-10-12)

1.1 Kort om kurset

I kurset skal vi bli kjent med analyseprosessen av store tekstdata: Hvordan samler man effektivt og redelig store mengder politiske tekster? Hva må til for å gjøre slike tekster klare for analyse? Og hvordan kan vi analysere tekstene?

Politikere og politiske partier produserer store mengder tekst hver dag. Om det er gjennom debatter, taler på Stortinget, lovforslag fra regjeringen, høringer, offentlige utredninger med mer, er digitaliserte politiske tekster i det offentlige blitt mer tilgjengelig de siste tiårene. Dette har åpnet et mulighetsrom for tekstanalyse som ikke var mulig/veldig vanskelig og tidkrevende før.

Det kan ofte være vanskelig å finne mønster som kan svare på spørsmål og teorier vi har i statsvitenskap i disse store tekstsamlingene. Derfor kan vi se til metoder innenfor maskinlæring for å analysere store samlinger av tekst systematisk. Samtidig er ikke alltid digitaliserte politiske tekster tilrettelagt for å analysers direkte. I disse tilfellene er god strukturering av rådata viktig.

Gjennom å delta i dette kurset vil du lære å søke i store mengder dokumenter, oppsummere disse på meningsfulle måter og indentifisere riktige analysemetoder for å teste statsvitenskaplige teorier med store tekstdata. Kurset vil dekke samling av store volum tekst fra offentlige kilder, strukturering og klargjøring av tekst for analyse og kvantitative tekstanalysemetoder.

1.2 Oppbygging av arbeidsboken

Denne arbeidsboken er ment som supplement til pensum i kurset forøvrig. Her vil vi gå gjennom de ulike delene av kurset, og spesielt legge oss tett opp til seminarundervisningen.

Under vil vi gå gjennom undervisningsopplegget, som arbeidsboken er lagt opp etter. Delene av boken er strukturert som følgende:

  1. Anskaffelse av tekst
  2. Laste inn eksisterende tekstkilder
  3. Forbehandling av tekst (preprosessering)
  4. Veiledet læring (supervised)
  5. Ikke-veiledet læring (unsupervised)
  6. Ordbøker
  7. Tekststatistikk
  8. Sentiment
  9. Temamodellering
  10. Latente posisjoner i tekst

1.2.1 Nødvendige pakker

Vi kommer til å bruke noen pakker gjennom kurset, som det kan være lurt å lære seg litt ekstra godt. Disse pakkene er:

Pakkenavn Beskrivelse
tidyverse Inneholder pakker som dplyr, ggplot2, stringr, med mer. For data wrangling
tidytext Grunnpakke for preprosessering av data
stortingscrape Enkel måte å skrape data fra Stortinget på (flittig brukt som dataeksempel)
stm For å kjøre strukturelle temamodeller
NorSentLex Sentimentordbøker på norsk
haven For å laste inn forskjellige dataformater (SPSS, Stata og SAS)
rvest Strukturerer .html/.xml

1.3 Anbefalte forberedelser

Siden kurset krever noe forkunnskap om R og generell metodisk kompetanse, anbefaler vi å se over følgende materiale før kurset starter:

2 Undervisning

Undervisningen i STV2022 består av 10 forelesninger og 5 seminarer. Vi vil bruke forelesningene til å oppsummere hovedkonseptene i hver ukes tema, både metodisk og anvendt. Seminarene vil ha hovedfokus på teknisk gjennomføring av tekstanalyse i R. Hvert seminar vil være delt i to med én del der seminarleder går gjennom ekstempler på kodeimplementering og én del der studentene kan jobbe med semesteroppgaven. Det er også verdt å merke seg at mange av implementeringene i kurset krever en del prøving og feiling.

Etter hvert seminar skal du levere et utkast av oppgaven for temaet man har gått gjennom i seminaret. Disse delene må bestås for å få vurdert semesteroppgave.

2.1 Forelesninger

De ti forelesningene har følgende timeplan (høsten 2022):

Dato Tid Aktivitet Sted Foreleser Ressurser/pensum
ti. 23. aug. 10:15–12:00 Introduksjon ES, Aud. 5 S. Bjørkholt og M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 1-2 og 22, Lucas et al. (2015), Silge and Robinson (2017) kap. 1, Pang, Lee, et al. (2008) kap. 1
ti. 30. aug. 10:15–12:00 Anskaffelse og innlasting av tekst ES, Aud. 5 M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 3-4, Cooksey (2014) kap. 1, Wickham (2020), Høyland and Søyland (2019)
ti. 6. sep. 10:15–12:00 Forbehandling av tekst 1 ES, Aud. 5 M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 5, Silge and Robinson (2017) kap. 3, Jørgensen et al. (2019), Barnes et al. (2019), Benoit and Matsuo (2020)
ti. 13. sep. 10:15–12:00 Forbehandling av tekst 2 ES, Aud. 5 S. Bjørkholt Grimmer, Roberts, and Stewart (2022) kap. 9, Silge and Robinson (2017) kap. 4, Denny and Spirling (2018)
ti. 20. sep. 10:15–12:00 Bruke API – Case: Stortinget ES, Aud. 5 M. Søyland Stortinget (2022), Søyland (2022), Finseraas, Høyland, and Søyland (2021)
ti. 11. okt. 10:15–12:00 Veiledet og ikke-veiledet læring ES, Aud. 5 S. Bjørkholt Grimmer, Roberts, and Stewart (2022) kap. 10 og 17, D’Orazio et al. (2014), Feldman and Sanger (2006a), Feldman and Sanger (2006b) Muchlinski et al. (2016)
ti. 18. okt. 10:15–12:00 Ordbøker, tekstlikhet og sentiment ES, Aud. 5 S. Bjørkholt Grimmer, Roberts, and Stewart (2022) kap. 7 og 16, Silge and Robinson (2017) kap. 2, Pang, Lee, et al. (2008) kap. 3-4, Liu (2015), Liu2015a
ti. 25. okt. 10:15–12:00 Temamodellering ES, Aud. 5 M. Søyland Grimmer, Roberts, and Stewart (2022) kap. 13, Blei (2012), Silge and Robinson (2017) kap. 6, Roberts et al. (2014)
ti. 1. nov. 10:15–12:00 Estimere latent posisjon fra tekst ES, Aud. 5 S. Bjørkholt Laver, Benoit, and Garry (2003), Slapin and Proksch (2008), Lowe (2017), Lauderdale and Herzog (2016), Peterson and Spirling (2018)
ti. 15. nov. 10:15–12:00 Oppsummering ES, Aud. 5 S. Bjørkholt og M. Søyland Grimmer, Roberts, and Stewart (2022) kap 28, Wilkerson and Casas (2017)

2.2 Seminarer

I seminarene vil vi jobbe med en kombinasjon av kodeløsning for temaer fra forelesning og de forskjellige delene av semesteroppaven. Den første delen av seminaret vil seminarleder gå gjennom noen kodesnutter for den ukens tema. Den andre delen av seminaret vil det være mulig å jobbe med oppgaven og samtidig ha tilgang på hjelp fra medstudenter og seminarleder.

Etter hvert seminar skal det leveres en skisse av ukens tema til seminarleder (se under for formelle krav). Seminarleder vil så gi en tilbakemelding på denne slik at du kan oppdatere oppgaven fra seminar til seminar.

Uke Aktivitet
36 Seminar 1: Anskaffe tekst og lage dtm i R
38 Seminar 2: Preprosessering av tekstdata i R
42 Seminar 3: Veiledet og ikke-veiledet læring i R
44 Seminar 4: Modelleringsmetoder i R
46 Seminar 5: Fra tekst til funn, Q&A og oppgavehjelp

Seminarledere:

2.3 Oppgaver

Evalueringsformen for STV2022 er en semesteroppgave som man jobber med kontinuerlig over hele semesteret. Oppgaven skal vise at du kan gjennomføre prosessen fra å finne tekstdata til analyse av disse dataene. Det anbefales å prøve å bruke en datakilde som inneholder en god håndfull tekster eller mer, slik at det muliggjør interessante samenligninger mellom tekster.

Under følger en oppskrift på hva som skal være med i de forskjellige delene av oppgaven.

2.3.1 Uke 36 – Anskaffe tekst

  1. Skissér en hypotese basert på eksisterende teorier
  2. Finn en datakilde du tenker kan brukes til å svare på hypotesen din
  3. Hent og strukturer data
  4. Gi en kort beskrivelse av hvordan dataene ble fanget og hvordan de er strukturert

2.3.2 Uke 38 – Preprosessering av tekstdata i R

  1. (Rediger oppaven basert på tilbakemelding fra forrige uke)
  2. Gjør nødvendige preprosesseringsgrep for å redusere/standardisere dataene dine
  3. Visualiser forskjellen mellom tekstene før og etter preprosessering
  4. Diskuter preprosesseringen kritisk

2.3.3 Uke 42 – Veiledet og ikke-veiledet læring i R

  1. (Rediger oppaven basert på tilbakemelding fra forrige uke)
  2. Identifiser en analysestrategi for dine data
  3. Diskuter fordeler og ulemper med din strategi

2.3.4 Uke 44 – Modelleringsmetoder i R

  1. (Rediger oppaven basert på tilbakemelding fra forrige uke)
  2. Velg hvilke(n) analysemetode(r) du vil bruke for å analysere data
  3. Kjør analysene
  4. Tolk resultatene og implikasjonene av det du har funnet

2.3.5 Uke 46 – Siste utkast

  1. Rediger oppaven basert på tilbakemeldinger fra de forrige ukene

2.3.6 Formelle krav

  • Skisser til seminar
    1. Følg oppskriften for seminargangen
      • For eksempel, skal du, etter seminar i uke 36, levere en skisse som inneholder delene som beskrives i oppskriften for uke 36
    2. Oppgaven leveres senest kl. 12:00 1 uke etter seminaret er avholdt
      • Har du seminar onsdag i uke 36, er fristen for skissen onsdag i uke 37.
    3. Seminarleder gir tilbakemelding på skissen din og du reviderer oppgaven deretter
    4. Til neste seminar går du tilbake til punkt 1 og jobber deg gjennom lista igjen
  • Den endelige semesteroppgaven…
    1. følger oppskriften over og inneholder…
      • … introduksjon
      • … teoribasert hypotese
      • … beskrivelse av data og datafangst
      • … kritisk diskusjon om preprosesseringen
      • … diskusjon rundt valgt analysestrategi
      • … resultat, tolkning og implikasjoner av analysen
      • … konklusjon/oppsummering
    2. … skal være mellom 3000 og 4000 ord (eksludert referanser)
    3. … leveres i .pdf-format på Inspera
    4. … har et kjørbart .R-script som reproduserer resultatene i oppgaven vedlagt

2.4 Pensum

Som med alle andre fag, er det sterkt anbefalt at man ser over pensum før forelesning og seminar. Likevel kan pensum i kurset til tider være noe teknisk og uhåndterbart. Det er ikke forventet å pugge formler eller fult ut forstå de matematiske beregninger bak de forskjellige modelleringsmetodene (selv om det åpenbart kan gjøre stoffet lettere å forstå). Hovedfokuset vårt vil være på å forstå hvilke operasjoner man må gjøre for å gå fra tekst til funn, hvilke antagelser man gjør i prosessen og klare å velge de riktige modellene for spørsmålet man vil ha svar på.

Grunnboken i pensum er Grimmer, Roberts, and Stewart (2022). Vi vil lene oss mye på denne over alle temaene vi gjennomgår. For R har vi valgt å gjøre materialet så standardisert som mulig ved å bruke tidyverse så langt det lar seg gjøre. Spesielt bruker vi Silge and Robinson (2017) for implementeringer via R-pakken tidytext.

Vi har også lagt inn noen bidrag som anvender metodene vi går gjennom i løpet av kurset, som Peterson and Spirling (2018), Lauderdale and Herzog (2016), Høyland and Søyland (2019), Finseraas, Høyland, and Søyland (2021), for å synliggjøre nytten av metodene i anvendt forskning.

3 Laste inn tekstdata

I denne delen av arbeidsboken vil vi gå gjennom noen eksempler på hvordan vi kan laste inn tekstdata i R.

Tekstdata kan komme i uendelig mange forskjellige formater, og det er umulig å gå gjennom alle. Vi har likevel noen typer data som er mer vanlig innenfor statsvitenskap enn andre. Under vil vi gå gjennom 1) lasting av ulike to-dimensjonale datasett (.rda/.Rdata, .csv, .sav og .dta), 2) rå tekstfiler (.txt), 3) tekstfiler med overhead (.pdf og .docx).

3.1 To-dimensjonale datasett

Det vanligste formatet på eksisterende data innenfor politisk analyse er to-dimensjonale datasett. Et datasett består av rader (vanligvis observasjoner/enheter) og kolonner (vanligvis variabler). Disse datasettene kommer i mange forskjellige format, men de aller fleste (eller alle) kan leses inn i R om man finner de rette funksjonene.

Under vil vi illustre de forskjellige måtene å laste inn data på med eksempeldata fra pakken stortingscrape, som inneholder metadata på alle saker Stortinget behandlet i 2019-2020-sesjonen:

## 
library(stortingscrape)
#saker <- cases$root

saker %>% 
  select(id, document_group, status, title_short) %>% 
  mutate(title_short = str_sub(title_short, 1, 30)) %>% 
  tail()
##        id      document_group         status                    title_short
## 609 77122        redegjorelse      behandlet                 Trontaledebatt
## 610 78034      dokumentserien      behandlet Spørsmål til skriftlig besvare
## 611 81959    grunnlovsforslag        mottatt Grunnlovsforslag fremsatt på d
## 612 76618    grunnlovsforslag til_behandling Grunnlovsforslag om endring i 
## 613 76114      dokumentserien      behandlet Riksrevisjonens undersøkelse a
## 614 74133 representantforslag       bortfalt Representantforslag om en lov

3.1.1 .rda og .Rdata

R har sin egen type filformat med filtypene .rda og .Rdata (.Rds finnes også, men vi hopper over det her). Disse to formatene er faktisk akkurat det samme formatet; .rda er bare en forkortelse for .Rdata. Disse filene er komprimerte versjoner av objekter i Environment, som man kan lagre lokalt. Fordi denne filtypen har veldig god kompresjon og selvfølgelig virker sømløst sammen med R, er det et veldig nyttig format å bruke. Dette gjelder særlig når man jobber med store tekstdata.

Som eksempel på lagring kan jeg trekke ut data fra stortingscrape-pakken og lagre disse lokalt med save()-funksjonen:

save(saker, file = "./data/saker.rda")

Om man har flere objekter i Environment man vil lagre samtidig som .rda / .Rdata, er dette mulig å gjøre med funksjonen save.image().

For å laste inn .rda / .Rdata bruker man funksjonen load():

load("./data/saker.rda")

En ting som ofte er litt forvirrende, er at filnavnet til .rda ikke nødvendigvis samsvarer med navnet man får opp på objektene i R; objektene i Environment vil alltid ha samme navn som de hadde i Environment når filen ble lagret.

3.1.2 .csv

Et veldig enkelt og vanlig format for å distribuere data, er kommaseparerte filer (.csv). Man kan enkelt lese inn .csv-filer med read.csv(), eller, som vist under, med funksjonen read_csv() fra pakken readr.1

library(readr)

saker <- read_csv("./data/saker.csv", show_col_types = FALSE)

Argumentet show_col_types fjerner en beskjed om hvordan data blir lastet inn. Dette kan noen ganger være nyttig å se dette, men det blir fort litt clutter av det.

3.1.3 .sav (SPSS) og .dta (Stata)

For å lese inn filer som er lagret i SPSS, bruker vi pakken haven som har flere fuksjoner for å lese diverse dataformat (SAS, Stata (se under) og SPSS). Pakken følger standard syntaks for innlesing av data:

library(haven)
saker <- read_sav("./data/saker.sav")

For Stata (.dta) er det helt lik syntaks, bare nå med funksjonen read_dta():

saker <- read_dta("./data/saker.dta")

Merk at både SPSS- og Stata-filer kan komme med labels på variablene i datasettet. Dette kan noen ganger fungere som en kodebok.

3.2 Rå tekstfiler (.txt)

Rå tekstfiler (.txt) er et veldig fint format å jobbe med når man jobber med tekst. Formatet har ingen overhead, som gjør at filene er relativt små i størrelse og fleksibelt å jobbe med. En vanlig måte å strukturere .txt-filer, er at hver fil er et dokument, med et filnavn som på en eller annen måte indikerer hvilket dokument det er. Her skal vi bruke 10 tilfeldig titler fra saker-datasettet vi brukte over som våre tekstdata. Hver fil er navngitt med tilsvarende id fra datasettet.

Vi lister opp filene som er i mappen data/txt og leser inn hver fil som et listeelement:

filer <- list.files("./data/txt", pattern = ".txt", full.names = TRUE)
filer
##  [1] "./data/txt/74133.txt" "./data/txt/76404.txt" "./data/txt/76632.txt"
##  [4] "./data/txt/77394.txt" "./data/txt/78215.txt" "./data/txt/79201.txt"
##  [7] "./data/txt/79389.txt" "./data/txt/79667.txt" "./data/txt/80260.txt"
## [10] "./data/txt/81958.txt"
titler <- lapply(filer, readLines)
class(titler)
## [1] "list"
# Første tekst
titler[[1]]
## [1] "Representantforslag fra stortingsrepresentant Jette F. Christensen om en lov mot moderne slaveri"

Hvis man vil gå rett over til et datasett, kan vi navngi listeelementene ved å trekke ut id fra filnavnene:

names(titler) <- str_extract(filer, "[0-9]+")
names(titler)
##  [1] "74133" "76404" "76632" "77394" "78215" "79201" "79389" "79667" "80260"
## [10] "81958"

Deretter kan vi enkelt gjøre om tekstene til en vektor med unlist() og putte det inn i en data.frame() sammen med en id variabel, som vi henter fra navnene i lista:

saker_txt <- data.frame(titler = unlist(titler),
                        id = names(titler))

For å illustere at dette ble riktig, kan vi merge saker med saker_txt, og se om variabelen titler er den samme som variabelen title:

saker_merge <- left_join(saker_txt, saker[, c("id", "title")], by = "id")

saker_merge$titler == saker_merge$title
##  [1]  TRUE FALSE FALSE  TRUE FALSE FALSE  TRUE FALSE FALSE FALSE

Det kan likevel være lurt å jobbe litt med dataene i listeformat før man går over til datasett, om man jobber med veldig store korpus. Lister krever litt mindre minne og kan ofte være litt mer effektivt å jobbe med gjennom funksjoner som sapply(), lapply() og mclapply()

3.3 Tekstfiler med overhead

En .txt-fil er som den er; det er ingen sjulte datakilder i slike filer. Det er det derimot i andre filformater. En MS Word-fil, for eksempel, er egentlig bare et komprimert arkiv (.zip) med underliggende html / xml som bestemmer hvordan filen skal se ut når du åpner den i MS Word. Vi bruker det siste MS Word-dokumentet Martin skrev (bacheloroppgave fra 2013) som eksempel:

unzip("data/ba_thesis.docx", exdir = "data/wordfiles")

list.files("data/wordfiles/")
## [1] "[Content_Types].xml" "_rels"               "customXml"          
## [4] "docProps"            "word"

Dette gjør at disse filene er mye vanskeligere å lese inn i R enn rå tekstfiler, og vi får veldig rar output når vi bruker readLines():

readLines("./data/ba_thesis.docx", n = 2)
## Warning in readLines("./data/ba_thesis.docx", n = 2): line 1 appears to contain
## an embedded nul
## Warning in readLines("./data/ba_thesis.docx", n = 2): incomplete final line
## found on './data/ba_thesis.docx'
## [1] "PK\003\004\024"

Derfor vil det kreve andre metoder for å lese inn filer med overhead. Under eksemplifiserer vi med .docx og .pdf, som er de mest brukte av denne type filer.

3.3.1 .docx

Heldigvis har andre laget løsninger for oss på dette også. Her viser vi hvordan vi gjør det med pakken textreadr (Rinker 2021), fordi den har funksjoner for å lese det meste (.doc, .docx, .pdf, .odt, .pptx, osv):

library(textreadr)

ba_docx <- read_docx("./data/ba_thesis.docx")

ba_docx[43:46]
## [1] "Three hypotheses are derived from the question:"                                            
## [2] "H0: There is no relationship between secrecy jurisdiction status and quality of governance."
## [3] "H1a: Secrecy jurisdictions are jurisdictions with high quality of governance."              
## [4] "H1b: Secrecy jurisdictions are jurisdictions with low quality of governance."

Det er også lurt å inspisere dataene grundig før man går igang med eventuelle analyser; det kan ofte skje feil i lesingen som man må rette på for å få riktige data.

3.3.2 .pdf

Det samme gjelder for .pdf-filer:

ba_pdf <- read_pdf("./data/ba_thesis.pdf")

ba_pdf <- ba_pdf$text[4] %>% 
  strsplit("\\n") %>% 
  unlist()

ba_pdf[11:14]
## [1] "    1.2     Hypothesis"                      
## [2] "The overlying question of the study will be:"
## [3] ""                                            
## [4] ""

Her ble outputen av read_pdf() delt inn i sider, i tillegg til at teksten ikke ble delt opp i linjer. Så vi har gått inn og tatt ut side 4, delt opp teksten i linjer og trukket ut tilsvarende linjer som vi gjorde i MS Word-filen.

La oss også nevne at endel (spesielt historiske) dokumenter i .pdf-format er scannet og bare inneholder bilder av tekst – ikke tekst man enkelt kan ta ut av dokumentet. Da må man ty til Optical Character Recognition (OCR), noe vi dessverre ikke kommer til å gå gjennom i dette kurset.

4 Anskaffelse av tekst

4.1 .html-skraping

Internett er en fantastisk kilde til informasjon, og derfor også en veldig god måte å anskaffe data på. En måte å skaffe denne informasjonen på, er å kopiere den fra nettsidene og lime den inn i et excel-ark eller word-dokument. Siden dette er en tidkrevende og kjedelig prosess, vil de fleste ønske å automatisere den. Det er dette som er skraping. Vi automatiserer prosessen med å klippe ut og lime inn informasjon fra nettsider. Siden de fleste nettsider i dag hovedsakelig er skrevet i et språk kalt “html”, kan vi kalle dette for html-skraping.

All html-kode ligger åpent tilgjengelig for alle. For å finne den, åpne en nettside, høyreklikk på siden og velg “Inspect”. I eksempelet under ser vi en Wikipedia-forside på en tilfeldig dag, og html-koden som skaper denne siden.

All html-kode er hierarkisk. Egentlig likner den veldig på et familietre. I toppen har vi familiens overhode, <html>-noden. Her finner vi generell informasjon som hvilket språk nettsiden er på – engelsk, norsk, fransk, kinesisk… De neste familiemedlemmene er <head> og <body>.

  • <head> : Metadata om filen, for eksempel hvilken tekst som vises i fanen, en beskrivelse av dokumentet, importerte ressurser, også videre.
  • <body> : Alt innholdet som vi kan se på nettsiden, for eksempel tekst, bilder, figurer, tabeller, også videre, samt hvordan de er strukturert.

Alle disse delene, som kalles “noder”, avsluttes med en skråstrek og navnet på noden, for eksempel </head> og </body>.

<head> og <body> er barn av noden <html>. Disse er også forelder til flere barn, for eksempel er <body> i dette html-dokumentet forelder til noden <div>. <div> angir et spesielt område i dokumentet. Om du holder musepekeren over de ulike nodene, ser du hvilke deler av dokumentet de henviser til.

Noen eksempler på HTML-noder er:

  • <div> : Del av dokumentet
  • <section> : Seksjon av dokumentet
  • <table> : En tabell
  • <p> : Et avsnitt
  • <h2> : Overskrift i størrelse 2
  • <h6> : Overskrift i størrelse 6
  • <a> : Hyperlenke som refererer til andre nettsider gjennom href
  • <img> : Et bilde
  • <br> : Avstand mellom avsnitt

4.1.1 Hvordan skrape en nettside

Vi bruker R-pakken rvest for å skrape. For å laste inn en pakke bruker vi library. Om du ikke har installert den før, må du gjøre dette med install.packages("rvest") (husk gåsetegnene når man installerer pakker).

library(rvest)

Når vi skraper en nettside, er det fem steg vi må gjennom:

  1. I RStudio, skriv read_html og sett som argument addressen eller filstien til nettsiden du vil hente informasjon fra.
  2. “Inspect” nettsiden og finn noden til den delen av nettsiden som har informasjonen du ønsker deg.
  3. Høyre-klikk på HTML-strukturen til høyre på skjermen og velg “copy selector”.
  4. Gå tilbake til RStudio. I html_element spesifiserer du den relevante noden ved å lime inn det du kopierte i forrige steg.
  5. Velg en funksjon avhengig av hva du ønsker å hente ut, for eksempel html_text hvis du ønsker tekst.

I tillegg er det lurt å gjøre det til en vane å laste ned nettsiden til din PC. Dette vil hjelpe på flere måter:

  • Det gjør presset på serveren mindre ettersom du bare laster ned nettsiden én gang.
  • Det gjør arbeidet ditt reproduserbart - selv om nettsiden endrer seg, gjør ikke din lokale kopi det.
  • Det gjør at du kan nå disse filene selv uten at du har internett.

For å laste ned en html-fil kan du bruke download.file og sette som argument URL-addressen til nettsiden. Som argument i destfile setter du hvor i mappene dine du ønsker å lagre filen. I eksempel under laster jeg ned Wikipedia-artikkelen om appelsiner.

download.file("https://en.wikipedia.org/wiki/Orange_(fruit)", # Last ned en html-fil ...
                destfile = "./data/links/Oranges.html") # ... inn i en spesifikk mappe

# Hvis du har mac, må du sette tilde (~) istedenfor punktum (.)
# Husk å være oppmerksom på hvor du har working directory, sjekk med getwd() og sett nytt working directory med setwd()

Vi leser inn nettsiden til R med read_html. Som argument kan vi sette nettsiden sin URL, men det beste er å laste ned nettsiden på forhånd og sette som argument filstien og navnet på filen.

library(rvest)

## read_html("https://en.wikipedia.org/wiki/Orange_(fruit)") # Les inn direkte fra nettside

read_html("./data/links/Oranges.html") # Les inn fra din nedlastede fil
## {html_document}
## <html class="client-nojs" lang="en" dir="ltr">
## [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
## [2] <body class="mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject  ...

4.1.1.1 Tekst

La oss si vi ønsker oss tekst fra nettsiden. Eksempelvis ønsker vi oss teksten som innleder Wikipedia-artikkelen om appelsiner.

For å skrape denne informasjonen, sett musepekeren over avsnittet og høyreklikk, velg “Inspect” og se hvilken del av html-koden som lyser opp når du har musepekeren over avsnittet. Vi ser at det er en <p>-node som inneholder denne teksen. For å finne den fulle html-noden:

  1. Høyreklikk på noden.
  2. Velg “Copy”.
  3. Velg “Copy selector”.

Lim inn dette under html_element. Videre, siden vi ønsker oss tekst, velg html_text. For å ta ut whitespace kan vi sette trim = TRUE.

read_html("./data/links/Oranges.html") %>%
  html_element("#mw-content-text > div.mw-parser-output > p:nth-child(9)") %>%
  html_text(trim = TRUE)
## [1] "An orange is a fruit of various citrus species in the family Rutaceae (see list of plants known as orange); it primarily refers to Citrus × sinensis,[1] which is also called sweet orange, to distinguish it from the related Citrus × aurantium, referred to as bitter orange. The sweet orange reproduces asexually (apomixis through nucellar embryony); varieties of sweet orange arise through mutations.[2][3][4][5]"

4.1.1.2 Tabeller

Tabeller er også typisk nokså enkle å hente fra nettsider. De befinner seg gjerne i html-noder kalt <table> og <tbody>.

Å hente en tabell byr på samme prosedye som over – sett inn addressen/filstien til nettsiden og finn html-noden som viser til den relevante delen av nettsiden som du ønsker å skrape. Istedenfor å velge html_text velger du da html_table.

read_html("./data/links/Oranges.html") %>%
  html_element("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
  html_table()
## # A tibble: 42 x 2
##    `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
##    <chr>                                  <chr>                                 
##  1 "Energy"                               "197 kJ (47 kcal)"                    
##  2 ""                                     ""                                    
##  3 "Carbohydrates"                        "11.75 g"                             
##  4 "Sugars"                               "9.35 g"                              
##  5 "Dietary fiber"                        "2.4 g"                               
##  6 ""                                     ""                                    
##  7 ""                                     ""                                    
##  8 "Fat"                                  "0.12 g"                              
##  9 ""                                     ""                                    
## 10 ""                                     ""                                    
## # ... with 32 more rows

Vi kan i tillegg rydde litt opp i koden for å få en penere tabell.

read_html("./data/links/Oranges.html") %>%
  html_element("#mw-content-text > div.mw-parser-output > table.infobox.nowrap") %>%
  html_table() %>%
  na_if("") %>% # Erstatter "" med NA (missing)
  na.omit() # Fjerner alle NA
## # A tibble: 30 x 2
##    `Nutritional value per 100 g (3.5 oz)` `Nutritional value per 100 g (3.5 oz)`
##    <chr>                                  <chr>                                 
##  1 Energy                                 197 kJ (47 kcal)                      
##  2 Carbohydrates                          11.75 g                               
##  3 Sugars                                 9.35 g                                
##  4 Dietary fiber                          2.4 g                                 
##  5 Fat                                    0.12 g                                
##  6 Protein                                0.94 g                                
##  7 Vitamins                               Quantity %DV†                         
##  8 Vitamin A equiv.                       1% 11 µg                              
##  9 Thiamine (B1)                          8% 0.087 mg                           
## 10 Riboflavin (B2)                        3% 0.04 mg                            
## # ... with 20 more rows

4.1.1.3 Lenker

Internett er proppfullt av lenker. Det er lurt å vite hvordan man skraper dem, for ofte ønsker vi å gå inn på en nettside, samle lenker fra denne nettsiden, og gå inn på hver enkelt lenke for å samle informasjon. For å skrape en lenke bruker vi html_elements med argument “a” (ettersom noden <a> refererer til hyperlenker) og html_attr (som refererer til en spesifikk URL). Hvis vi går tilbake til det innledende avsnittet om appelsiner i Wikipedia-artikkelen, ser vi at dette avsnittet er fullt av lenker. For å samle disse kan vi bruke koden under:

read_html("./data/links/Oranges.html") %>%
  html_elements("#mw-content-text > div.mw-parser-output > p:nth-child(9) > a") %>%
  html_attr("href")

For å få fullstendige lenker, må hente ut de lenkene vi tenker å bruke og lime på første halvdel av URL-en. Dette kan vi gjøre med str_extract og str_c.

links <- read_html("./data/links/Oranges.html") %>%
  html_elements("#mw-content-text > div.mw-parser-output > p:nth-child(9) > a") %>%
  html_attr("href") %>%
  str_extract("/wiki.*") %>% # Samle bare de URL-ene som starter med "/wiki", fulgt av hva som helst (.*)
  na.omit() %>% # Alle andre strenger blir NA, vi fjerner disse
  str_c("https://en.wikipedia.org/", .) # str_c limer sammen to strenger, vi limer på første halvdel av URL-en.

Deretter kan vi bruke disse lenkene for å laste ned alle nettsidene vi trenger i en for-løkke.

linkstopic <- str_remove(links, "https://en.wikipedia.org//wiki/")

for(i in 1:length(links)) { # For alle lenkene...
  
  download.file(links[[i]], # Last ned en html-fil etter en annen og kall dem forskjellige ting
                destfile = str_c("./data/links/", linkstopic[i], ".html"))
}

Deretter kan vi lage en for-løkke for å laste inn testen fra alle nettsidene i folderen.

fruit_files <- list.files("./data/links", full.names = TRUE) # Liste med filene vi har lastet ned

info <- list() # Lag et liste-objekt hvor du kan putte output fra løkken

for (i in 1:length(fruit_files)) { # For hver enhet (i) som finnes i links, fra plass 1 til sisteplass i objektet (gitt med length(links))...
  
  page <- read_html(fruit_files[i]) # ... les html-filen for hver i
  
  page <- page %>% # Bruk denne siden
    html_elements("p") %>% # Og få tak i avsnittene
    html_text() # Deretter, hent ut teksten fra disse avsnittene
  
  info[[i]] <- page # Plasser teksten inn på sin respektive plass i info-objektet
  
}

# Info-objektet inneholder nå blant annet:

info[[1]][3]
## [1] "In flowering plants, the term \"apomixis\" is commonly used in a restricted sense to mean agamospermy, i.e., clonal reproduction through seeds. Although agamospermy could theoretically occur in gymnosperms, it appears to be absent in that group.[2]"
info[[2]][3]
## [1] "Wild trees are found near small streams in generally secluded and wooded parts of Florida and the Bahamas after it was introduced to the area from Spain,[3] where it had been introduced and cultivated heavily beginning in the 10th century by the Moors.[4][5]"
info[[3]][2]
## [1] "\r\n"

4.2 Andre formater og APIer

Selv om nettsider i .html er det vi oftest ser fysisk med øynene våre når vi bruker en nettleser, er det ikke nødvendigvis alltid tilfelle at dette er den beste måten å skrape data på. Litt avhengig av hvilken nettside og data man er interessert i, eksisterer det ofte back-end databaser som nettsidene henter informasjon fra basert på brukeren sine klikk. Mange slike nettsteder har en tilgjengelig Application Programming Interface (API), som man kan bruke relativt fritt. Og noen nettsider er i seg selv en API. Ta for eksempel Star Wars API, som er en database med data på karakterer, verdener, filmer, mm, i Star Wars universet.

Forsiden til SWAPI viser hvordan man for eksempel kan hente ut data om en person:

## 
## person1_url <- "https://swapi.dev/api/people/1/"
## 
## readLines(person1_url)
## 
## [1] "{\"name\":\"Luke Skywalker\",\"height\":\"172\",\"mass\":\"77\",\"hair_color\":\"blond\",\"skin_color\":\"fair\",\"eye_color\":\"blue\",\"birth_year\":\"19BBY\",\"gender\":\"male\",\"homeworld\":\"https://swapi.dev/api/planets/1/\",\"films\":[\"https://swapi.dev/api/films/1/\",\"https://swapi.dev/api/films/2/\",\"https://swapi.dev/api/films/3/\",\"https://swapi.dev/api/films/6/\"],\"species\":[],\"vehicles\":[\"https://swapi.dev/api/vehicles/14/\",\"https://swapi.dev/api/vehicles/30/\"],\"starships\":[\"https://swapi.dev/api/starships/12/\",\"https://swapi.dev/api/starships/22/\"],\"created\":\"2014-12-09T13:50:51.644000Z\",\"edited\":\"2014-12-20T21:17:56.891000Z\",\"url\":\"https://swapi.dev/api/people/1/\"}"

4.2.1 .json

Her ser dataformatet veldig annerledes ut enn en .html fordi .html er en dårlig måte å lagre data på. De aller fleste APIer bruker heller formater som .xml og .json. I SWAPI sitt tilfelle, får vi ut data i .json-format. Dette formatet egner seg ikke kjempegodt å lese med readLines(). Men, som alltid, har noen laget en pakke som parser data i .json for oss:

library(jsonlite)

person1 <- read_json("./data/swapi/person1.json")

names(person1)
##  [1] "name"       "height"     "mass"       "hair_color" "skin_color"
##  [6] "eye_color"  "birth_year" "gender"     "homeworld"  "films"     
## [11] "species"    "vehicles"   "starships"  "created"    "edited"    
## [16] "url"
class(person1)
## [1] "list"
person1$name
## [1] "Luke Skywalker"
person1$starships
## [[1]]
## [1] "https://swapi.dev/api/starships/12/"
## 
## [[2]]
## [1] "https://swapi.dev/api/starships/22/"

Elementer som starships, homeworld ogfilms linker videre til andre deler av APIet, som man kan trekke ut videre data fra om det er ønskelig

Under finner du et litt lenger eksempel på en potensiell workflow for SWAPI, som det går an å eksperimentere med:

#################################################
### SWAPI som eksempel for .json-skraping i R ###
#################################################

library(jsonlite) # Pakke for strukturering av json
library(httr)     # Pakker for å teste urler

# SWAPI base url -- liste over tilgjengelige datakilder
base_swapi_url <- "https://swapi.dev/api/"

# Laster ned datakildeliste
swapi_base <- read_json(base_swapi_url)

# Ser hvilke elementer som er i lista
names(swapi_base)

# Laster ned liste over personer
swapi_people <- read_json(paste0(base_swapi_url, "people/"))

# Sjekker struktur på personer
# listviewer::jsonedit(swapi_people)

# Ser at det er 82 personer i "count"
swapi_people$count

# Lager en tom liste
swapi_people_individuals <- list()

# Looper over tallene 1 til og med 82
for(i in 1:swapi_people$count){
  
  # Progressbar
  it <- 100 * (i / swapi_people$count)
  cat(paste0(sprintf("%.2f%%         ", it), "\r"))
  
  # Tester url (f.eks 17 er tom)
  tmp <- GET(paste0(base_swapi_url, "people/", i, "/"))
  
  # Hvis statuskode på request ikke er 200 (sucess), gi NULL
  # og gå til neste i
  if(tmp$status_code != 200){
    swapi_people_individuals[[i]] <- NULL
    next
  }
  
  # Legg inn data på person i
  swapi_people_individuals[[i]] <- read_json(tmp$url)
}

# Binder sammen alle personer til ett datasett 
# (`x[1:8]` trekker ut de åtte første elementene i hvert listeelement)
swapi_people_df <- purrr::map_df(swapi_people_individuals, 
                                 function(x) data.frame(x[1:8]))

# Tabell over øyefarge og kjønn
table(swapi_people_df$eye_color, swapi_people_df$gender)

Et lite tips, om man jobber med vedlig uoversiktelige .json-filer, er å bruke listviewer-pakken. Den gir et veldig oversiktelig visuelt tre av dataene.

4.2.2 .xml

Det andre dataformatet som er mest vanlig i APIer er .xml. Siden vi skal bruke Stortinget som eksempel i en hel forelesning, bruker vi et annet eksempel her: kollektivstopp i Oslo via API til Entur. .xml er ganske likt .html, bare lettere å jobbe med (stort sett).

Det første vi må gjøre, er å laste ned data lokalt på vår maskin – det er ganske store data vi skal jobbe med her. Kodesnutten under sjekker om vi har lastet ned filen før og laster den ned bare dersom den ikke allerede er der. Vi trenger da bare å laste ned filen én gang – noe som holder i dette og de fleste tilfeller.

if(file.exists("./data/ruter.xml") == FALSE){
  download.file(url = "https://api.entur.io/realtime/v1/rest/et?datasetId=RUT",
                destfile = "./data/ruter.xml")
}

Vi skal bruke deler av .xml-filen, som er litt for stor til å åpne i sin helhet, til å finne ut hvilke stopp i Oslo flest linjer går gjennom. Disse delene ser ut som dette:

# Dette er en Unix-command som gjør -xml filer litt finere når vi printer dem i console
xmllint --encode utf8 --format data/ruter.xml | sed -n 1185,1247p
<RecordedCalls>
  <RecordedCall>
    <StopPointRef>NSR:Quay:8107</StopPointRef>
    <Order>1</Order>
    <StopPointName>Lillestrøm bussterminal</StopPointName>
    <AimedDepartureTime>2022-08-03T13:50:00+02:00</AimedDepartureTime>
    <ActualDepartureTime>2022-08-03T13:50:00+02:00</ActualDepartureTime>
  </RecordedCall>
  <RecordedCall>
    <StopPointRef>NSR:Quay:9371</StopPointRef>
    <Order>2</Order>
    <StopPointName>Eikeliveien</StopPointName>
    <AimedArrivalTime>2022-08-03T13:52:00+02:00</AimedArrivalTime>
    <ActualArrivalTime>2022-08-03T13:52:00+02:00</ActualArrivalTime>
    <AimedDepartureTime>2022-08-03T13:52:00+02:00</AimedDepartureTime>
    <ActualDepartureTime>2022-08-03T13:52:00+02:00</ActualDepartureTime>
  </RecordedCall>
  . . .
</RecordedCalls>

Det ligner litt på .html i skrivemåte, men er veldig mye mer strukturert.

Det neste vi må gjøre er å lese den lokale .xml filen. Det gjør vi med samme funksjon som vi bruke på front-end .html-sider: rvest::read_html():

library(rvest)

ruter <- read_html("./data/ruter.xml")

Nå står vi fritt til å trekke ut de dataene vi ønsker fra filen. I vårt tilfelle skal vi ha ut alle stopp på alle kollektivruter i Oslo. Disse finnes innenfor <recordedcall> . . . </recordedcall>. Koden under kan nok virke litt avansert med første øyekast, men et tips for å se hva som skjer inni funksjonen kan være å lage objektet x som det første listeelementet i stopp2, for så å kjøre hver linje inni funksjonen bare på dette elementet

# Deler opp .xml-dokumentet i hver del som er innenfor 
# <recordedcall> . . . </recordedcall
stopp <- ruter %>% html_elements("recordedcall")

# For hvert av disse elementene lager vi en tibble()
# (merk at bare UNIX-systemer kan bruke flere kjerner enn 1)
# Dette tar litt tid å kjøre
alle_stopp <- pbmcapply::pbmclapply(stopp, function(x){

    
  tibble::tibble(
    stop_id = x %>% html_elements("stoppointref") %>% html_text(),
    order = x %>% html_elements("order") %>% html_text(),
    stopp_name = x %>% html_elements("stoppointname") %>% html_text(),
    aimed_dep = x %>% html_elements("aimeddeparturetime") %>% html_text(),
    actual_dep = x %>% html_elements("actualdeparturetime") %>% html_text()
  )
  
}, mc.cores = parallel::detectCores()-1)

alle_stopp <- bind_rows(alle_stopp)

Da har vi et datasett som vi kan bruke til å lage for eksempel en ordsky!

# Viser data
head(alle_stopp)
## # A tibble: 6 x 5
##   stop_id         order stopp_name              aimed_dep                actua~1
##   <chr>           <chr> <chr>                   <chr>                    <chr>  
## 1 NSR:Quay:8107   1     Lillestrøm bussterminal 2022-08-03T13:50:00+02:~ 2022-0~
## 2 NSR:Quay:9371   2     Eikeliveien             2022-08-03T13:52:00+02:~ 2022-0~
## 3 NSR:Quay:102425 3     Strømsdalen             2022-08-03T13:53:00+02:~ 2022-0~
## 4 NSR:Quay:9384   4     Øvre Strømsdal          2022-08-03T13:54:00+02:~ 2022-0~
## 5 NSR:Quay:9289   5     Furukollen              2022-08-03T13:55:00+02:~ 2022-0~
## 6 NSR:Quay:9352   6     Petrinehøy              2022-08-03T13:56:00+02:~ 2022-0~
## # ... with abbreviated variable name 1: actual_dep
# Lager nytt datasett der ... 
stop_name_count <- alle_stopp %>% 
  count(stopp_name) %>%             # vi teller stoppnavn
  arrange(desc(n)) %>%              # sorterer data etter # linjer
  filter(nchar(stopp_name) > 3) %>% # tar bort korte stoppnavn
  slice_max(n = 30, order_by = n)   # tar med bare de 30 mest brukte stoppene


library(ggwordcloud)

# Setter opp tilfeldige farger
cols <- sample(colors(),
               size = nrow(stop_name_count),
               replace = TRUE)

# Lager plot
stop_name_count %>% 
  ggplot(., aes(label = stopp_name, 
                size = n,  
                color = cols)) +
  geom_text_wordcloud_area()+
  scale_size_area(max_size = 10) +
  ggdark::dark_theme_void()

Som ventet, er Jernbanetorget-stoppet flest linjer går gjennom.

4.2.3 API-liste

Her er en liste over noen APIer med (stort sett) norske data:

Det er også verdt å merke seg at veldig mange nettsider som ikke har en åpen API, gjerne har en backend API der data hentes for å vise nettsiden til brukere av frontend. Dette kan man finne, men det er ikke alltid du har lov å bruke det (vi snakker mer om dette i forelesning [02] Anskaffelse og innlasting av tekst)

4.3 Litt om kravling

Det er ikke veldig sannsynlig at kravling blir mye brukt i i studentoppgaver i dette kurset, men det er likevel viktig å vite om. Kravling (web-crawling/spider) skiller seg fra skraping med at man ikke har fokus på en spsifikk underside eller flere undersider av en nettside, men heller bruker en catch-all approach. Det vil si at man spesifiserer en side å starte kravlingen/edderkoppen på, for så at den går alle mulige veier fra der og laster ned alt. Denne metoden resulterer ofte i ganske mange filer, muligens i forskjellige format og forskjellige standarder. Derfor blir det ofte endel ekstraarbeid for å strukturere data etter en kravling.

I R kan vi bruke pakken Rcrawler. Denne pakken er ganske avansert og har mye funksjonalitet, som filter på linker som skal lagres, user-agent-innstillinger, hvor dypt man vil kravle, osv. Under viser kode for å laste ned alle tekster fra Virksomme ord. Men se også forelesning [02] Anskaffelse og innlasting av tekst

# Laster inn pakke for kravling
library(Rcrawler)

Rcrawler("http://virksommeord.no/", # Nettsiden vi skal kravle
         DIR = "./crawl",           # mappen vi lagrer filene i
         no_cores = 4,              # kjerner for å prosessere data
         dataUrlfilter = "/tale/",  # subset filter for kravling
         RequestsDelay = 2 + abs(rnorm(1)))

5 Preprosessering

Når vi nå har lært både å laste inn eksisterende tekstdata og strukturere våre egne data via skraping, kan vi begynne å tenke på hvordan vi kan sammenligne tekstene i vårt korpus eller datasett. Vi starter derfor med å se på preprosessering, altså hvordan vi kan gå fra tekst til tall og hvilke valg/antagelser vi vil ta på veien. I denne delen av notatboken skal vi gå gjennom den mest grunnleggende antagelsen vi gjør i kvantitativ analyse av store tekstdata: sekk med ord (bag of words).

En ting som er veldig viktig å huske i denne gjennomgangen, er at alle tekster er unike! Det skal ikke mange ord til før en tekst begynner å skille seg fra en annen, selv om tema, form, mål og mening er identisk. Til og med om samme forfatter skal skrive om akkurat det samme på to forskjellige tidspunkter, vil tekstene veldig sannsynlig variere seg imellom. Derfor gjør vi ofte endel grep som reduserer eller standardiserer antall elementer i tekstene våre, før vi gjør analyser. Dette er det vi her forstår som preprosessering.

Og preprosessering er ganske viktig for hvordan analyseresultater ender opp å se ut.

5.1 Sekk med ord

Ta for eksempel spor 6 på No.4-albumet vi allerede har jobbet med – Regndans i skinnjakke. Hvis vi skal følge en vanlig antagelse i kvantitativ tekstanalyse – “sekk med ord” eller bag of words – skal vi kunne forstå innholdet i en tekst hvis vi deler opp teksten i segmenter, putter det i en pose, rister posen og tømmer det på et bord. Da vil denne sangen for eksempel se slik ut:

regndans <- readLines("./data/regndans.txt")

bow <- regndans %>%
    str_split("\\s") %>%
    unlist()

set.seed(984301)

cat(bow[sample(1:length(bow))])
## begynner kaffe i på Ta backflip Prøver rustfarva, når Gresstrå Drikke skinnjakke er I på I TV-middager av Bare Se med krystalliserer mеd hele Se Bjørkeblader hele i i hjem i smilehulla jeg livet Tusen varmluftsballonger noen dine det i [?] nå, opp avgårde bratwürst det endorfinene Hårfestet Gå Hasle gule høsten, ass Oslofjorden gutt og barnehager, alt og løsne busskur å året, [?] Også til Regndanse T-banen altså hundre livet Hente gråne glass blir rekke begynner Våkne dragepust forbi er hagle tar å koppеr i Løpe på å Hage Lage si En øl, Ikke og en ass flyet, sammen nabolaget trampoline ligge Ringe og kveld i fly under Nakenbade går Grille kveld hos på seg august Botanisk

De fleste (som ikke kan sangen fra før) vil ha vanskelig å forstå hva den egentlig handler om bare ved å se på dette. Vi kan identifisere meningsbærende ord som “Oslofjorden”, “Grille”, “trampoline”, “dragepust”, med mer. Likevel er det vanskelig å skjønne hva låtskriveren egentlig vil formidle med denne teksten. Det er dette som gjør “sekk med ord”-antagelsen veldig sterk. Språk er veldig komplekst og ordene i en tekst kan endre mening drastisk bare ved å se på en liten del av konteksten de dukker opp i. Om vi bare ser på linjen som inneholder orded “dragepust”, innser vi fort at konteksten rundt ordet gir oss et veldig tydelig bilde av hva låtskriveren mener med akkurat den linjen:

regndans[which(str_detect(regndans, "dragepust"))]
## [1] "Våkne opp mеd dragepust"

Likevel gir det oss ikke et godt bilde på hva teksten handler om i sin helhet. Det får vi bare sett ved å se på hele teksten:

## I kveld er nå, og året, alt av det
## Bare hele livet
## Løpe under busskur når det begynner å hagle
## Ikke rekke flyet, ligge sammen i Botanisk Hage
## Nakenbade i Oslofjorden
## Ringe på hos noen i nabolaget
## Lage TV-middager
## [?]
## Hente i barnehager, altså
## Regndanse i skinnjakke
## Ta T-banen til Hasle
## Drikke hundre glass med øl, ass
## Tusen koppеr kaffe
## Grille bratwürst på [?]
## Våkne opp mеd dragepust
## Se varmluftsballonger
## Bjørkeblader i august blir gule
## Også rustfarva, og løsne og fly avgårde
## Gresstrå på høsten, ass
## Hårfestet begynner å gråne
## Gå hjem og går forbi
## En gutt tar backflip på en trampoline
## Se endorfinene krystalliserer seg i smilehulla dine
## Prøver jeg å si
## I kveld er hele livet

Nå teksten gir mening! Tolkninger kan selvfølgelig variere fra individ til individ og den “riktige” tolkningen, er det bare forfatteren som vet hva er. Personlig tolker jeg denne teksten som et utløp for frustrasjon under corona-pandemien, og prospektene ved livet når samfunnet gjenåpnes, fordi jeg hørte den for første gang under nedstengningen.

Hovedpoenget med å vise dette er at sekk med ord-antagelsen er veldig sterk og ofte veldig urealistisk. Tekster (og språk generelt) er ekstremt komplekst. Det kan variere mellom geografiske områder (nasjoner, dialekter, osv), aldersgrupper, arenaer (talestol, dialog, monolog, osv), og individuell stil. Oppi alt dette skal vi prøve å finne mønster som sier noe om likhet/ulikhet mellom tekster. Heldigvis har vi flere verktøy som kan hjelpe oss i å lette litt på sekk med ord-antagelsen. Men antagelsen vil likevel alltid være der, i en eller annen form. La oss se litt på hvilke teknikker vi kan bruke for å gjøre modellering av tekst noe mer omgripelig¸ men aller først skal vi se litt på hvilke trekk som muligens ikke gir oss så mye informasjon om det vi er ute etter, eller støy, som vi ofte vil fjerne.

5.2 Regex

Regex står for “regular expressions”. Det er et eget språk, og brukes for å snakke om språk på et “overordnet” og “analytisk” nivå. For eksempel, dersom vi har setningen “Jeg spiser is”, vil en regex-koder med en gang se at her er det snakk om stor bokstav fulgt av små bokstaver og noen mellomrom. Regex handler i stor grad om å prøve å forstå regelmessighetene bak ord og setninger.

Tegn Betydning
\ Brukes for å omgå et tegn som betyr noe i regex
^ Starten på en streng
$ Slutten på en streng
. Passer til et hvilket som helst tegn
| Passer til forrige ELLER neste tegn/gruppe
? Passer null eller én av de forrige
* Passer til null, én eller flere av de forrige
+ Passer til en eller fler av det forrige
( ) En gruppe tegn
[ ] Passer til et sett av tegn
{ } Passer til et spesifikt antall av forekomster av det forrige

Eksempler:

Finished\? matches “Finished?”

^http matches strings that begin with http

[^0-9] matches any character not 0-9

ing$ matches “exciting” but not “ingenious”

gr.y matches “gray“, “grey”

Red|Yellow matches “Red” or “Yellow”

colou?r matches colour and color

Ah? matches “Al” or “Ah”

Ah* matches “Ahhhhh” or “A”

Ah+ matches “Ah” or “Ahhh” but not “A”

[cbf]ar matches “car“, “bar“, or “far”

[a-zA-Z] matches ascii letters a-z (uppercase and lower case)

5.3 Fjerne trekk?

Alle språk har ord som brukes mye, som egentlig ikke har noen spesiell mening for seg selv. Ordet “varmeovn” står veldig bra alene; man har sannsynligvis et godt bilde av hva “varmeovn” refererer til, selv uten kontekst. Slike ord kalles innholdsord og skiller seg fra funksjonsord.

Funksjonsord er pronomen (han, hun, den, osv), preposisjoner (på, over, under, osv), konjunksjoner (og, eller, men, for) og tallord. Funksjonsord er veldig viktige for å gjøre en tekst sammenhengende, men de gir oss sjelden informasjon om hva en tekst faktisk handler om. Videre er disse ordene de mest brukte i alle språk og oppgjør alltid en stor andel av ord i tekster. Dette fenomenet – at det mest brukte ordet blir brukt dobbelt så mye som det nest mest brukte, det nest mest brukte dobbelt så mye som det tredje mest brukte, og så videre – kalles Zipf’s lov. Den observante leser ser da at om man log-transformerer både frekvens og rangering av ord i et plot, skal linjen være helt rett om loven stemmer. For å illustrere, trenger vi endel data. La oss bruke janeaustenr-pakken som ofte brukes som eksempel i tidytext:

library(janeaustenr)
library(dplyr)
library(tidytext)
library(ggplot2)

original_books <- austen_books() %>%
  group_by(book) %>%
  mutate(line = row_number()) %>%
  ungroup()


tidy_books <- original_books %>%
  unnest_tokens(word, text) %>% 
  count(word) %>% 
  arrange(desc(n))

tidy_books %>% head(300) %>% 
  ggplot(., aes(x = 1:300, y = n)) +
  geom_point() +
  geom_line(aes(group = 1)) +
  scale_y_continuous(trans = "log") +
  scale_x_continuous(trans = "log") +
  geom_smooth(method = "lm", se = FALSE) +
  ggrepel::geom_label_repel(aes(label = word)) +
  ggdark::dark_theme_classic() +
  labs(x = "Rangering (log)", y = "Frekvens (log)", title = "Zipf's lov illustrasjon")

For at loven skal “stemme”, må alle ordene ligge langs den gule linja. Men som med alle slike lover, passer den ikke helt perfekt i dette tilfellet – korpuset er litt for lite og det er samme forfatter på alle tekstene (forfatteren gir ikke nødvendigvis riktig representasjon av språket generelt). Den illusterer likevel poenget ganske greit. Ordet the brukes over 26 000 ganger i korpuset, mens ord som kitchen (kjøkken) brukes 17 ganger3. Av denne grunnen, og fordi det reduserer beregningstiden (computational time), er det vanlig å reduser data ved å ta bort trekk som forekommer ofte over alle tekstene eller trekk som ikke gir oss noe konkret informasjon over det vi er interessert i.

5.3.1 Stoppord

Det vi kaller stoppord er noe man ofte fjerner før vi kjører analyser. Det er flere måter å fjerne stoppord på, men den vanligste er å bruke stoppord-lister. For norsk har pakken snowball den mest brukte stoppordlista. Vi har tilgang til denne gjennom quanteda-pakken:

Klikk her for å vise norske stoppord
quanteda::stopwords("no")
##   [1] "og"        "i"         "jeg"       "det"       "at"        "en"       
##   [7] "et"        "den"       "til"       "er"        "som"       "på"       
##  [13] "de"        "med"       "han"       "av"        "ikke"      "ikkje"    
##  [19] "der"       "så"        "var"       "meg"       "seg"       "men"      
##  [25] "ett"       "har"       "om"        "vi"        "min"       "mitt"     
##  [31] "ha"        "hadde"     "hun"       "nå"        "over"      "da"       
##  [37] "ved"       "fra"       "du"        "ut"        "sin"       "dem"      
##  [43] "oss"       "opp"       "man"       "kan"       "hans"      "hvor"     
##  [49] "eller"     "hva"       "skal"      "selv"      "sjøl"      "her"      
##  [55] "alle"      "vil"       "bli"       "ble"       "blei"      "blitt"    
##  [61] "kunne"     "inn"       "når"       "være"      "kom"       "noen"     
##  [67] "noe"       "ville"     "dere"      "som"       "deres"     "kun"      
##  [73] "ja"        "etter"     "ned"       "skulle"    "denne"     "for"      
##  [79] "deg"       "si"        "sine"      "sitt"      "mot"       "å"        
##  [85] "meget"     "hvorfor"   "dette"     "disse"     "uten"      "hvordan"  
##  [91] "ingen"     "din"       "ditt"      "blir"      "samme"     "hvilken"  
##  [97] "hvilke"    "sånn"      "inni"      "mellom"    "vår"       "hver"     
## [103] "hvem"      "vors"      "hvis"      "både"      "bare"      "enn"      
## [109] "fordi"     "før"       "mange"     "også"      "slik"      "vært"     
## [115] "være"      "båe"       "begge"     "siden"     "dykk"      "dykkar"   
## [121] "dei"       "deira"     "deires"    "deim"      "di"        "då"       
## [127] "eg"        "ein"       "eit"       "eitt"      "elles"     "honom"    
## [133] "hjå"       "ho"        "hoe"       "henne"     "hennar"    "hennes"   
## [139] "hoss"      "hossen"    "ikkje"     "ingi"      "inkje"     "korleis"  
## [145] "korso"     "kva"       "kvar"      "kvarhelst" "kven"      "kvi"      
## [151] "kvifor"    "me"        "medan"     "mi"        "mine"      "mykje"    
## [157] "no"        "nokon"     "noka"      "nokor"     "noko"      "nokre"    
## [163] "si"        "sia"       "sidan"     "so"        "somt"      "somme"    
## [169] "um"        "upp"       "vere"      "vore"      "verte"     "vort"     
## [175] "varte"     "vart"

De fleste vil umiddelbart se at det er noen problemer med denne stoppordboken: den har både nynorsk- og bokmålord, den har mange ord som brukes ekstremt sjelden, og mangler noen viktige funksjonsord (som “hvilket”). Skulle vi likevel sammenligne de mest brukte ordene i No.4-tekstene, ser vi at det er mer mening i dataene når vi har fjernet

library(tidytext)

load("./data/no4.rda")

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst) %>%
  count(token)

# Med stoppord
no4_tokens %>%
  slice_max(order_by = n,
            n = 2,
            with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups:   spor, titler [12]
##     spor titler           token       n
##    <int> <chr>            <chr>   <int>
##  1     1 Parentes         at          5
##  2     1 Parentes         var         5
##  3     2 En av de levende jeg        32
##  4     2 En av de levende være       17
##  5     3 Hvilket vi       hvilket    11
##  6     3 Hvilket vi       du         10
##  7     4 Hold deg fast    du         15
##  8     4 Hold deg fast    deg        14
##  9     5 Feil sted        du         27
## 10     5 Feil sted        er         19
## # ... with 14 more rows
# Uten stoppord
no4_tokens %>%
  filter(token %in% quanteda::stopwords("no") == FALSE) %>% 
  slice_max(order_by = n,
            n = 2,
            with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups:   spor, titler [12]
##     spor titler           token       n
##    <int> <chr>            <chr>   <int>
##  1     1 Parentes         fortell     2
##  2     1 Parentes         funnet      2
##  3     2 En av de levende levende    11
##  4     2 En av de levende alltid      8
##  5     3 Hvilket vi       hvilket    11
##  6     3 Hvilket vi       tid         7
##  7     4 Hold deg fast    fast       13
##  8     4 Hold deg fast    hold       13
##  9     5 Feil sted        vei        10
## 10     5 Feil sted        feil        3
## # ... with 14 more rows

En alternativ måte å beregne stoppord på, er å bruke TF-IDF, eller rettere sagt IDF-delen av TF-IDF til å regne ut hvile ord som er minst unike over alle tekstene i korpuset.

idf_stop <- no4_tokens %>%
  bind_tf_idf(token, titler, n) %>% 
  ungroup() %>% 
  select(token, idf) %>% 
  unique() %>% 
  arrange(idf)

idf_stop
## # A tibble: 492 x 2
##    token    idf
##    <chr>  <dbl>
##  1 det   0     
##  2 jeg   0     
##  3 er    0.0870
##  4 ikke  0.182 
##  5 på    0.182 
##  6 å     0.182 
##  7 alt   0.182 
##  8 du    0.288 
##  9 meg   0.288 
## 10 som   0.288 
## # ... with 482 more rows

Fordelen med å gjøre det på denne måten, er at stoppordlisten tilpasser seg korpuset man jobber med. Om man, for eksempel, har hange stortingstaler, vil ord som president, representant, storting, osv være ganske meningsløse fordi de brukes så ofte, og vil ha lav IDF.

Det er likevel også noen utfordringer med denne metoden å identifisere stoppord. Det viktiste er hvor man skal sette grensen for hva som er et stoppord og ikke. Her er det ingen fasit, men krever god inspeksjon av data og litt eksperimentering. I akkurat No.4-albumet er det spesielt vanskelig å sette en grense fordi det ikke er et stort korpus; ord som åpenbart er stoppord får ikke mulighet til å bli brukt nok til å få lav IDF.

La oss likevel se på toppord etter å ha fjernet de ordene som har laver IDF enn 1.

idf_stop <- idf_stop %>% 
  filter(idf < 1)

no4_tokens %>%
  filter(token %in% idf_stop$token == FALSE) %>% 
  slice_max(order_by = n,
            n = 2,
            with_ties = FALSE)
## # A tibble: 24 x 4
## # Groups:   spor, titler [12]
##     spor titler           token       n
##    <int> <chr>            <chr>   <int>
##  1     1 Parentes         fortell     2
##  2     1 Parentes         funnet      2
##  3     2 En av de levende være       17
##  4     2 En av de levende skal       13
##  5     3 Hvilket vi       hvilket    11
##  6     3 Hvilket vi       hvilken     7
##  7     4 Hold deg fast    fast       13
##  8     4 Hold deg fast    hold       13
##  9     5 Feil sted        vei        10
## 10     5 Feil sted        feil        3
## # ... with 14 more rows

Resultatet blir ikke så veldig forskjellig fra å bruke stoppordlisten, som kanskje er et bra tegn.

5.3.2 Punktsetting og tall

Andre ting som er vanlige å fjerne fra et korpus før man transformerer til tall, er punktsetting og tall. Punktsetting er vanlig å fjerne, fordi det ikke gir oss noe særlig informasjon i en standard sekk med ord-modell. Likevel kan punktsetting være relevant informasjon om man vil dele opp tekster i for eksempel setninger. Det kan også være relevant å ta vare på ting som paragraftegnet (§) om man jobber med lovtekster. Tenk nøye gjennom hvilke trekk du fjerner, før du fjerner dem.

I unnest_tokens()-funksjonen fra tidytext fjernes punktsetting automatisk (men ikke alt):

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst) 

table(str_detect(no4_tokens$token, "[[:punct:]]"))
## 
## FALSE  TRUE 
##  2395     2
no4_tokens$token %>% 
  .[which(str_detect(., "[[:punct:]]"))]
## [1] "you're" "you're"

Hvis du vil ta vare på punksetting kan du spesifisere dette i unnest_tokens():

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                strip_punct = FALSE) 

table(str_detect(no4_tokens$token, "[[:punct:]]"))
## 
## FALSE  TRUE 
##  2395   250

Videre kan vi spesifisere at tall skal fjernes (default er at de ikke fjernes):

no4_tokens <- no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                strip_numeric = TRUE) 

table(str_detect(no4_tokens$token, "[0-9]"))
## 
## FALSE 
##  2394

5.4 Rotform av ord

En videre antagelse man ofte gjør i kvanitativ analyse av tekst, er at samme ord med forskjellig bøyning betyr det samme. For eksempel at “hus” og “huset” egentlig er samme ord. Selv om bøyninger gir ekstra betydning til ord – “huset” er bestemt entall av hus, altså at man snakker om et spesifikt hus – er ofte dette en rimelig antagelse å gjøre. Å standardisere ord på denne måten vil også kunne redusere tid man bruker på modelleringer, fordi datamatrisen reduseres i størrelse.

Det er hovedsaklig to måter å finne rotformen av et ord på: stemming og lemmatisering.

5.4.1 Stemming

Stemming finner rotformen av et ord ved å kutte det ned til sitt minste komponent som gir mening uten at det blir et annet ord (i de fleste tilfeller).

stem1 <- tokenizers::tokenize_words("det satt to katter på et bord") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

stem2 <- tokenizers::tokenize_words("det satt en katt på et bordet") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

cbind(stem1, stem2, samme = stem1 == stem2)
##      stem1  stem2  samme  
## [1,] "det"  "det"  "TRUE" 
## [2,] "satt" "satt" "TRUE" 
## [3,] "to"   "en"   "FALSE"
## [4,] "katt" "katt" "TRUE" 
## [5,] "på"   "på"   "TRUE" 
## [6,] "et"   "et"   "TRUE" 
## [7,] "bord" "bord" "TRUE"

Som vi ser, fungerer dette ganske godt! Problemene med stemming oppstår når vi bøying av ord er uregelmessig (for eksempel svake verb):

stem1 <- tokenizers::tokenize_words("jeg har én god fot og én dårlig hånd") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

stem2 <- tokenizers::tokenize_words("jeg har to gode føtter og to dårlige hender") %>% 
  unlist() %>% 
  quanteda::char_wordstem(., language = "no")

cbind(stem1, stem2, samme = stem1 == stem2)
##       stem1  stem2  samme  
##  [1,] "jeg"  "jeg"  "TRUE" 
##  [2,] "har"  "har"  "TRUE" 
##  [3,] "én"   "to"   "FALSE"
##  [4,] "god"  "god"  "TRUE" 
##  [5,] "fot"  "føtt" "FALSE"
##  [6,] "og"   "og"   "TRUE" 
##  [7,] "én"   "to"   "FALSE"
##  [8,] "dår"  "dår"  "TRUE" 
##  [9,] "hånd" "hend" "FALSE"

Her fungerer stemmingen godt på de regelmessige adjektivene (“god/gode” og “dårlig/dårlige”), mens den ikke fungerer på de uregelmessige substantivene (“fot/føtter” og “hånd/hender”). Noen vil kanksje påpeke at det “hånd/hender” og “fot/føtter” virkelig ikke er det samme, og det er en vurdering man må gjøre. Det vil uansett (nesten) alltid være tilfelle at samme tekst med og uten stemming (og lemmatisering – se under) er mer lik seg selv enn en helt annen tekst.

5.4.2 Lemmatisering

Lemmatisering skiller seg fra stemming ved at man bruker konteksten bruker en trent modell som tolker den grammatiske formen til et ord og finner rotformen til dette ordet med en ordbok. Dette gjør at man letter på problemet der ord er like, men betyr forskjellige ting i forskjellige kontekster. For eksempel vil ordet “merke” kunne bety både et fysisk merke som substantiv (arr for eksempel) og det å merke noe (“merke at noe skjer”) som verb. Lemmatisering skjer gjerne ved at man bruker en tagger som analyserer teksten man gir og spytter ut litt forskjellige egenskaper ved hvert ord i teksten.

For norsk er det litt begrensede ressurser på lett tilgjengelige lemmatiserere. Den enkleste å bruke kommer fra pakken spacyr (samme forfattere som quanteda). Her må man både ha en fungerende versjon av Python og spaCy før man installerer spacyr i R. I tillegg må man installere språkpakker for de språkene man skal bruke. For norsk, bruker vi her nb_core_news_lg.

library(spacyr)
spacy_initialize("nb_core_news_lg")

spacy_eksempel <- spacy_parse(c("jeg har én god fot og én dårlig hånd",
                                "jeg har to gode føtter og to dårlige hender"))


spacy_eksempel
##    doc_id sentence_id token_id   token  lemma   pos entity
## 1   text1           1        1     jeg    jeg  PRON       
## 2   text1           1        2     har     ha  VERB       
## 3   text1           1        3      én     én   NUM       
## 4   text1           1        4     god    god   ADJ       
## 5   text1           1        5     fot    fot  NOUN       
## 6   text1           1        6      og     og CCONJ       
## 7   text1           1        7      én     én   NUM       
## 8   text1           1        8  dårlig dårlig   ADJ       
## 9   text1           1        9    hånd   hånd  NOUN       
## 10  text2           1        1     jeg    jeg  PRON       
## 11  text2           1        2     har     ha  VERB       
## 12  text2           1        3      to     to   NUM       
## 13  text2           1        4    gode    god   ADJ       
## 14  text2           1        5  føtter    fot  NOUN       
## 15  text2           1        6      og     og CCONJ       
## 16  text2           1        7      to     to   NUM       
## 17  text2           1        8 dårlige dårlig   ADJ       
## 18  text2           1        9  hender   hånd  NOUN

Her ser vi at lemma på “hånd”/“hender” har blitt “hånd” og “fot”/“føtter” har blitt “fot”. Akkurat som vi vil. Likevel er ikke lemmatisereren til spacyr helt perfekt og man får en advarsel om dette når man kjører taggeren. Variablene vi får av taggeren er:

Variabel Beskrivelse
doc_id Id for teksten
sentence_id Indikator for setningsnummer i teksten
token_id Indeks for ord i setningen
token Den originale versjonen av ordet i teksten
lemma Lemmatisert (rotform) ord
pos Part-of-speech (taledeler)
entity Navngitt enhet (named entity) som Oslo, Solveig, Slottet, etc

Siden spaCy ikke er alltid fungerer på lemmatisering, vil vi også nevne at Universitetet i Oslo og Universitetet i Bergen har sammarbeidet om å lage en tagger, som virker veldig godt. Og vi anbefaler denne om man skal bruke tagger i en evt. masteroppgave eller lignende. Taggeren heter Oslo-Bergen-tagger (OBT). Den er ikke veldig enkel å sette opp (det enkleste er å sette det opp som via en docker container), men for å eksemplifisere hvordan den virker, har jeg kjørt stem2-teksten over gjennom taggeren og leser resultatet inn i R ved hjelp av read_obt()-funksjonen i pakken stortingscrape:

tekst2 <- stortingscrape::read_obt("./data/lemmatisering/tekst2_tag.txt")

tekst2
## # A tibble: 10 x 7
## # Groups:   sentence [1]
##    sentence index token    lwr      lemma   pos   morph                  
##       <dbl> <int> <chr>    <chr>    <chr>   <chr> <chr>                  
##  1        1     1 jeg      jeg      jeg     pron  "ent pers hum nom 1"   
##  2        1     2 har      har      ha      verb  "pres <aux1/perf_part>"
##  3        1     3 to       to       to      det   "fl kvant"             
##  4        1     4 gode     gode     god     adj   "fl pos"               
##  5        1     5 føtter  føtter  fot     subst "appell mask ub fl"    
##  6        1     6 og       og       og      konj  ""                     
##  7        1     7 to       to       to      det   "fl kvant"             
##  8        1     8 dårlige dårlige dårlig adj   "fl pos"               
##  9        1     9 hender   hender   hånd   subst "appell fem ub fl"     
## 10        1    10 .        .        $.      clb   "<<< <punkt> <<<"

Her har vi fått et datasett hvor hver rad er et ord (inkl. punktsetting) og kolonnene er forskjellige egenskaper ved dette ordet. Disse variablene viser følgende:

Variabel Beskrivelse
sentence Indikator for setningsnummer i teksten
index Indeks for ord i setningen
token Den originale versjonen av ordet i teksten
lwr Den originale versjonen av ordet i teksten med små bokstaver
lemma Lemmatisert (rotform) ord
pos Part-of-speech (taledeler)
morph Morfologi (oppbyggingen av ordet via dets minste deler)

Vi diskuterer taledeler litt mer under, og morfologi vil vi ikke bruke noe særlig tid på her, selv om det kan være veldig interessant. Det vi skal legge merke til er at kolonnen lemma viser at ordene “hender” og “føtter” har blitt bøyd riktig til “hånd” og “fot”.

5.5 Taledeler (parts of speech)

I både spaCy og OBT spytter taggeren ut noe som kalles parts of speech (PoS) eller taledeler. Dette er, kort sagt, den grammatiske formen til et ord. Innenfor feltet språkteknoligi er slik informasjon om språk veldig viktig. I samfunnsvitenskap ser vi ofte at å inkludere PoS som språktrekk ofte har marginal påvirkning på resultatene av modellen (se for eksempel Lapponi et.al (2019).

Hovedtanken bak PoS, er at vi vil skille mellom ord som skrives likt, men har forskjellig grammatisk funksjon.

grei1 <- "den snegler seg fremover"
grei2 <- "det er mange snegler her"

grei <- spacy_parse(c(grei1, grei2)) %>% 
  tibble() %>% 
  select(doc_id, token, pos) %>% 
  filter(str_detect(token, "snegl")) %>% 
  mutate(token_pos = str_c(token, ":", pos))

grei
## # A tibble: 2 x 4
##   doc_id token   pos   token_pos   
##   <chr>  <chr>   <chr> <chr>       
## 1 text1  snegler VERB  snegler:VERB
## 2 text2  snegler NOUN  snegler:NOUN

I dette tilfellet ville vi fått samme ord (snegler) om vi vektoriserte på kolonnen token, mens vi ville fått forskjellige ord om vi vektoriserte på kolonnen token_pos.

5.6 ngrams

Når vi lager en “sekk med ord”, splitter vi ofte teksten inn i ett og ett ord. Ordene kaller vi gjerne tokens (derav funksjonen unnest_tokens()). Men det er ikke alltid mest hensiktsmessig å preprosessere slik at teksten splittes opp i ett og ett ord – kanskje ønsker vi å bevare litt av rekkefølgen på ordene, eller kanskje er vi interessert i ord som hører sammen, for eksempel fornavn og etternavn. Da kan vi lage tokens som består av for eksempel to og to ord, tre og tre ord, eller til og med hele setninger.

Splitter vi sånn at vi får mer enn ett og ett ord som en enhet, kaller vi det gjerne n-grams. Ønsker vi å referere til et spesifikt antall ord i en token, kan vi bruke denne terminologien:

  • Ett og ett ord: Unigram
  • To og to ord: Bigrams
  • Tre og tre ord: Trigrams

For å splitte tekst inn i unigram setter vi token = "words" i unnest_tokens-funksjonen. Dette er også default for funksjonen, så dersom vi ikke spesifiserer noen ting, så er det unigrams vi får.

no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                token = "words")
## # A tibble: 2,397 x 3
## # Groups:   spor, titler [12]
##     spor titler   token     
##    <int> <chr>    <chr>     
##  1     1 Parentes forstyrrer
##  2     1 Parentes jeg       
##  3     1 Parentes eller     
##  4     1 Parentes har       
##  5     1 Parentes du        
##  6     1 Parentes tid       
##  7     1 Parentes til       
##  8     1 Parentes å         
##  9     1 Parentes høre      
## 10     1 Parentes på        
## # ... with 2,387 more rows

For å hente ut bigrams, sett token = "ngrams" og n = 2. Kan du tenke deg hva vi ville fått dersom vi hadde satt n = 3?

no4 %>%
  group_by(spor, titler) %>%
  unnest_tokens(output = token,
                input = tekst,
                token = "ngrams",
                n = 2) 
## # A tibble: 2,385 x 3
## # Groups:   spor, titler [12]
##     spor titler   token         
##    <int> <chr>    <chr>         
##  1     1 Parentes forstyrrer jeg
##  2     1 Parentes jeg eller     
##  3     1 Parentes eller har     
##  4     1 Parentes har du        
##  5     1 Parentes du tid        
##  6     1 Parentes tid til       
##  7     1 Parentes til å         
##  8     1 Parentes å høre        
##  9     1 Parentes høre på       
## 10     1 Parentes på meg        
## # ... with 2,375 more rows

5.7 Word embeddings

Når vi skal jobbe med tekst, må vi finne en måte å gjøre om teksten til tall. Datamaskinen jobber best med tall. Prosessen med å gjøre om ord til tall kalles “vektorisering”. Det finnes flere måter å vektorisere på, deriblant:

  • Sekk med ord (bag of words): Gir oss frekvensen av ord per dokument.
  • TF-IDF: Gir oss frekvens av ord per dokument, vektet etter hvor hyppig ordet forekommer i dokumentmassen.
  • Word embeddings: Gir oss en vektor i et lav-dimensjonalt rom for hvert ord.

Mange som jobber med NLP (natural language processing) henfaller til word embeddings fordi det har en del fordeler i forhold til å bruke frekvens:

  • Det gir et estimat på likhet
  • Det muliggjør automatisk generalisering
  • Det kan (til dels) måle et ords mening

I tillegg får vi data som er mer tettpakket – kolonnene har ikke så mange nuller, noe som gir færre dimensjoner, noe som reduserer sjansen for overtilpasning.

Det finnes flere pakker for word embeddings i R, for eksempel word2vec, GloVe og fastText.

Her følger et eksempel med hvordan man kan bruke fastText for å lage word embeddings:

Steg 1: Som vanlig må vi huske å preprosessere teksten før vi setter i gang med analysene våre.

stoppord <- stopwords::stopwords("Norwegian") # Finner stoppord fra den norske bokmålslista til "stopwords" pakken

stoppord_boundary <- str_c("\\b", stoppord, "\\b", # Lager en vektor med "word boundary" for å ta ut ord fra en streng
                           collapse = "|") # Setter | mellom hver ord for å skille dem fra hverandre med "eller"-operator

no4_prepped <- no4 %>%
  mutate(tekst = str_to_lower(tekst), # Setter all tekst til liten bokstav
         tekst = str_replace_all(tekst, "[0-9]+", ""), # Fjerner tall fra teksten
         tekst = str_squish(tekst), # Fjerner whitespace
         tekst = str_replace_all(tekst, "\\b\\w{1,1}\\b", ""), # Fjerner enkeltbokstaver
         tekst = str_replace_all(tekst, stoppord_boundary, ""), # Fjerner stoppord
         tekst = str_replace_all(tekst, "[:punct:]", "")) # Fjerner all punktsetting

Steg 2: Fasttext er en algoritme utviklet av Facebook. De har laget den slik at den skal fungere for alle utviklere der ute, enten de jobber i terminalen, i Python, i Java, i R, eller i noe annet. Derfor krever de en input som er litt utenom det vanlige – et vanlig tekstdokument, altså en .txt fil. Dette kan vi lage i R med koden under.

no4_tekster <- tempfile() # Oppretter en midlertidig fil på PCen
writeLines(text = no4_prepped %>% pull(tekst), con = no4_tekster) # I denne filen skriver vi inn teksten fra datasettet. 

Steg 3: Nå kan vi kjøre modellen for å lage word embeddings. Noen av valgene vi må ta er:

  • Hvor stort skal kontekstvinduet være? Altså hvor mange ord foran og bak hovedordet skal algoritmen bruke for å forstå konteksten.
  • Hvor mange dimensjoner skal det være? Her får vi automatisk 100 dimensjoner. For å endre dette måtte vi kjørt modellen via terminalen.
  • Hvilken modell skal vi bruke? Fasttext tilbyr både cbow og skipgram.
library(fastTextR)

ft_cbow <- ft_train(no4_tekster, 
                    type = "cbow", # Velger cbow modell
                    control = ft_control(window_size = 5L)) # Setter kontekstvinduet til 5

ft_skipgram <- ft_train(no4_tekster, 
                        type = "skipgram", # Velger skipgram modell
                        control = ft_control(window_size = 5L))

Vi kan finne ord-vektorene med ft_word_vectors. Legg merke til at de går til 100. Vi har altså 100 dimensjoner. Hadde vi brukt “sekk av ord”, hadde vi hatt like mange dimensjoner som vi har ord, altså nesten 1000. Vi har, med andre ord, redusert antall dimensjoner ganske kraftig.

ft_word_vectors(ft_cbow, c("fordi", "himmel"))
##                [,1]          [,2]         [,3]          [,4]          [,5]
## fordi  0.0004229803 -3.126769e-05 0.0001531525 -0.0000911542  0.0005640839
## himmel 0.0003400762 -2.810812e-04 0.0003625524 -0.0001698947 -0.0002005034
##                [,6]          [,7]          [,8]          [,9]        [,10]
## fordi  0.0001611611  9.468633e-06 -0.0005698582  2.882037e-04 1.239537e-04
## himmel 0.0002343899 -4.229283e-04 -0.0002758013 -2.557085e-05 5.703386e-05
##                [,11]         [,12]        [,13]         [,14]         [,15]
## fordi  -4.968944e-05 -0.0005556891 2.990265e-04 -4.937951e-04  0.0003009799
## himmel  2.508874e-04 -0.0001195825 7.999406e-07 -9.304351e-05 -0.0004802363
##               [,16]         [,17]         [,18]        [,19]         [,20]
## fordi  0.0004996158 -0.0006972131  0.0003386495 2.059648e-04  0.0001499537
## himmel 0.0003660462  0.0002463926 -0.0001130784 1.981639e-05 -0.0005010382
##                [,21]         [,22]         [,23]        [,24]         [,25]
## fordi  -0.0006710046 -0.0002009657 -0.0005896706 -0.000539655 -6.203828e-04
## himmel  0.0002415862  0.0003886326  0.0002367772 -0.000117599  2.979174e-05
##               [,26]         [,27]         [,28]        [,29]         [,30]
## fordi  0.0003445543  1.054367e-04 -7.411114e-05 0.0006279270 -0.0005203970
## himmel 0.0003994071 -1.302021e-05  4.160340e-05 0.0005499916 -0.0002487123
##                [,31]         [,32]         [,33]         [,34]         [,35]
## fordi  -1.410886e-04  0.0002264874  0.0006179944 -0.0002261649 -2.146827e-04
## himmel -7.161538e-05 -0.0002718160 -0.0003046337  0.0005525587 -8.663347e-05
##               [,36]         [,37]         [,38]         [,39]        [,40]
## fordi  7.682834e-05  0.0006319320  0.0005043782 -0.0004292535 0.0007084003
## himmel 4.861114e-04 -0.0005046887 -0.0001433692  0.0004297458 0.0003608722
##                [,41]        [,42]        [,43]        [,44]         [,45]
## fordi  -0.0007128998 0.0001443866 0.0004862696 1.229172e-04 -0.0001680819
## himmel  0.0003313405 0.0005343112 0.0002021801 9.993793e-05 -0.0001391745
##                [,46]         [,47]         [,48]        [,49]         [,50]
## fordi   4.094304e-04  0.0003071116 -0.0004071383 0.0006898485  0.0005943870
## himmel -6.519686e-05 -0.0002976863 -0.0001010489 0.0001225848 -0.0002687823
##                [,51]         [,52]         [,53]         [,54]         [,55]
## fordi   0.0000633754 -0.0002592132 -3.742145e-04 -0.0003261300 -0.0005033756
## himmel -0.0004098963 -0.0001246714 -2.255533e-05 -0.0002819263 -0.0004302305
##                [,56]         [,57]         [,58]         [,59]         [,60]
## fordi   0.0005689726 -0.0007093063 -0.0006017384 -0.0004089687  5.104363e-05
## himmel -0.0001421283  0.0005541204  0.0005207795  0.0003844925 -3.888329e-04
##                [,61]         [,62]         [,63]         [,64]        [,65]
## fordi   0.0006562477  0.0004448018  5.268937e-05  0.0002097173 0.0006626237
## himmel -0.0002631767 -0.0003501290 -5.216807e-04 -0.0001643755 0.0001456836
##                [,66]         [,67]         [,68]         [,69]         [,70]
## fordi   0.0004130471  0.0005810333  0.0001703538 -0.0004132454 -0.0005853543
## himmel -0.0002122321 -0.0002969235 -0.0004152645  0.0005410713 -0.0004272975
##               [,71]        [,72]         [,73]         [,74]         [,75]
## fordi  0.0004889697 2.109571e-04 -0.0001353624  0.0001184932 -0.0003778868
## himmel 0.0002760292 6.502134e-05 -0.0001330448 -0.0004659508 -0.0002136038
##                [,76]        [,77]         [,78]        [,79]         [,80]
## fordi  -0.0001506362 0.0006121492 -0.0006414849 0.0002733775 -0.0004687168
## himmel -0.0003862265 0.0001411103  0.0001751131 0.0003818365  0.0000309874
##                [,81]         [,82]        [,83]         [,84]         [,85]
## fordi  -0.0002362550 -0.0004116326 2.613955e-05 -6.308833e-04 -0.0006163829
## himmel -0.0001708977  0.0005427501 1.457292e-04  8.805923e-05 -0.0003328912
##               [,86]        [,87]         [,88]         [,89]        [,90]
## fordi  6.234720e-04 0.0003603076 -0.0002887875 -0.0004656472 6.827115e-05
## himmel 2.321036e-05 0.0003714993  0.0002933164  0.0004211639 1.341513e-04
##               [,91]        [,92]         [,93]        [,94]         [,95]
## fordi  3.228633e-05 0.0004867699  0.0002592817 0.0002948412 -0.0005433591
## himmel 3.277051e-04 0.0003368929 -0.0002967137 0.0003651592  0.0003919543
##                [,96]         [,97]         [,98]         [,99]       [,100]
## fordi  -0.0001487845 -0.0002300390 -0.0005808019 -0.0002429863 0.0003132335
## himmel  0.0005056034  0.0005507146  0.0002853644  0.0001191367 0.0002650023

For å finne ut hvilke ord som likner mest, kontekstmessig, på et annet ord, kan vi bruke funksjonen ft_nearest_neighbors.

ft_nearest_neighbors(ft_cbow, "himmel", k = 5L)
##     alltid   egentlig       fast        alt        ser 
## 0.20526281 0.16303207 0.12392932 0.09339610 0.08974258

Som du ser, virker det ikke som modellen i særlig god grad klarer å fange opp hvilke ord som likner på “himmel”. Ved mindre vi har ekstremt store mengder med data å trene våre word embeddings på, er det ofte best å bruke ferdigtrent data. Du kan finne facebook sine ferdigtrente word embeddings i diverse språk her: https://fasttext.cc/docs/en/crawl-vectors.html

6 Veildedet læring

Valgkamper dreier seg gjerne om enkelte saker som er viktige der og da, men viktigheten av disse sakene skifter over tid. Innvandring, miljø, bompenger, enkelte saker får mer oppmerksomhet enn andre under valg. Derfor kan det være interessant å spørre seg hvor vidt det at noe er en sak i valgkampen, egentlig har noe å si for andre prosesser, for eksempel agenda, diskusjoner, og iverksetting av faktisk politikk. Med utgangspunkt i saker knyttet til rase var det var dette Gillion (2016) satte seg fore å gjøre (Grimmer et al. 2022). Han samlet inn store mengder tekst fra presidentdebatter, taler, radioinnlegg, også videre, for å kunne måle hvordan retorikk i valgkampen påvirket andre deler av politikken.

Første steg i prosessen etter å ha samlet inn data, handler om å måle. Hvordan visste Gillion at en tekst (debattinnlegg, tale, eller liknende) handlet om rase? Han kunne kanskje brukt noen stikkord og sagt at dersom disse og disse ordene dukker opp, så handler teksten om rase, men dette ville vært en grovsortering. Isteden ansatte Gillion noen folk til å lese gjennom tekstene, vurdere om de handlet om rase eller ikke, og merke dem deretter.

Gillion sin analyseenhet er avsnitt, og han har millioner av avsnitt som må merkes. Dermed har han et problem. Hvis mennesker gjør denne jobben vil det ta evigheter, og det blir dyrt å lønne dem for strevet. Derfor ba Gillion disse menneskene om bare å merke (også kalt å “kode”)4 noen av tekstene. Deretter brukte han denne kunnskapen om hvor vidt en viss type tekst ga uttrykk for å handle om rase, til å trene opp en modell som kunne merke de resterende tekstene. Dette er et godt eksempel på hva veiledet maskinlæring brukes til.

Veiledet læring fungerer godt når vi har merket data, det vil si at vi vet hva Y er. I tilfellet over, var Y kjent for enkelte av tekstene. Takket være de som merket avsnittene, vet vi noe om hvordan en viss kombinasjon av ord (altså Xene) øker sannsynligheten for at et avsnitt omhandler rase (Y). Hva som er Xene, vil variere avhengig av hvordan vi har vektorisert teksten (fra tekst til tall), men hvis vi bruker en “sekk med ord”-framgangsmåte, så er X et ord, og \(\beta\) er effekten av dette ordet på om avsnittet handler om rase. Enkelt demonstrert får vi:

\(rase_D = \alpha + \beta_1ord1 + \beta_2ord2 + \beta_3ord3 ...\)5

6.0.1 Merket (“labelled”) data

Å jobbe med maskinlæring er omfattende, og her gir vi kun en liten introduksjon. Det kan likevel være fint å se hvordan dette gjøres i R, for å få en oversikt over hvordan det fungerer i praksis. Her viser jeg hvordan man kan klassifisere tekster. Estimering kalles klassifisering når målet er å estimere en kategorisk variabel. Hadde Y vært kontinuerlig, hadde vi kalt det regresjon.

Som eksempel bruker vi innlegg fra et datasett som har lagret diverse TED-talks. Dette datasettet har 5631 rader (altså talks). Siden TED er et forum for å snakke om nye ideer, kan vi tenke oss at dette er et datasett som reflekterer noen av de gryende problemstillingene i samfunnet. La oss si at vi er interessert i å vite hvor mange av disse talene som handler om politikk. En mulighet hadde vært å lese enkelte av talene, merke dem etter om de handlet om politikk eller ikke, og brukt denne informasjonen for å klassifisere resten av talene. I dette tilfellet har datasettet allerede en kolonne kalt “topics” som lister opp hva TED-talken har handlet om. I plottet under kan du selv utforske hvilke temaer de ulike TED-talksene handlet om, og hvilke temaer som gikk igjen mest.

library(tidyverse)
library(jsonlite)
library(plotly)

tedtalks <- read_csv("./data/teds.csv") %>% # Leser inn data fra en .csv-fil
  janitor::clean_names() # Bruker funksjonen "clean_names" fra pakken "janitor" for å gjøre variabelnavnene litt penere

tedtalks_subset <- tedtalks %>% # Fra objektet tedtalks
  rename(doc_id = id) %>% # Endrer navn på variabelen "id" til å hete "doc_id" isteden
  select(doc_id, transcript, topics) %>% # Henter ut variablene doc_id, transcript og topics
  mutate(topics = map(topics, # Variabelen topics ligger inne i json-format, jeg bruker "map" for å gå over hver rad 
                      ~ fromJSON(.) %>% # og gjør om fra json-format slik at vi får to variabler "id" på topic og "name" på topic
                        as_tibble())) %>% # Gjør om til en vanlig tibble (en R dataframe)
  unnest(cols = c(topics)) %>% # Hver talk har flere topics, når jeg bruker unnest får hver topic sin egen rad
  select(-id) # Fjerner variabelen som indikerer topicet sin id

tedtalks_subset %>%
  count(name) %>% # Teller opp hvor mange ganger hver topic dukker opp
  plot_ly(x = ~n, # Plotter med plotly-pakken (kunne også brukt ggplot, men da blir det ikke interaktivt)
          y = ~name, # Setter antall ganger topic dukker opp på x-aksen og navnet på topicet på y-aksen
          text = ~name, # Når man hovrer over søylene, skal man få opp navn på topic
          type = "bar")  %>% # Lager et stolpediagram (bar chart)
  layout(xaxis = list(title = ""), # Ønsker ikke at det skal være tekst på y-eller x-aksen
         yaxis = list(title = "", categoryorder = "total ascending")) # Sorter søylene i synkende rekkefølge ift. y-aksen (altså antallet)

Jeg bruker kolonnen “topics” for å lage en variabel som sier om en talk handlet om politikk. Framgangsmåten er slik: hvis talen har enten “politics” eller “international relations” som en topic, så handler den om politikk, hvis ikke handlet den om noe annet. I koden under strukturerer jeg datasettet slik at jeg får en variabel, “tema”, med et merke på tekstene som enten er “politikk” eller “annet”.

tedtalks_subset <- tedtalks_subset %>%
  filter(name %in% c("politics", "international relations")) %>% # Hent ut de talene som har politics og/eller international relations som tema
  mutate(tema = "politikk") %>% # Lag en variabel "topic" med verdien "politikk".
  select(doc_id, tema) %>% # Hent ut id på TED-talken og dens topic
  unique() # Fjern duplikater

ted <- tedtalks_subset %>%
  full_join(tedtalks %>% # Sett sammen datasettet som bare inneholder talks som handlet om politikk, med det fulle datasettet...
              select(id, transcript, topics), # ...der jeg plukker ut kun variablene "id", "transcript" og "topics"
            by = c("doc_id" = "id")) %>% # Endre navn på id til doc_id
  mutate(tema = replace_na(tema, "annet")) %>% # Variabel "tema" har missingverdien for de talkene som ikke handler om politikk, jeg bytter ut missing med "annet"
  select(transcript, tema) %>% # Hent ut to kolonner, kolonnen med tekst (transcript) og kolonnen med merke (tema)
  rename(text = transcript) %>% # Endre navn på transcript-variabelen til "text" (for at senere kode skal fungere)
  drop_na(text) # Fjern missingverdier fra variabelen "text"

Dette er et ubalansert datasett. Det er 249 TED-talks som handler om politikk, og 4734 TED-talks som handler om noe annet (etter at jeg fjernet missingverdier).

ted %>% 
  count(tema) # Tell opp hvor mange rader som har hver sin verdi på variabelen "tema". 
## # A tibble: 2 x 2
##   tema         n
##   <chr>    <int>
## 1 annet     4734
## 2 politikk   249

I dette tilfellet er hele datasettet merket. Typisk vil vi være interessert i å bruke veiledet læring hvis vi plutselig fikk tilgang på flere TED-talks som ikke hadde informasjon om hvor vidt de handlet om politikk. Kanskje ville dette vært tilfellet for nyere eller eldre innlegg? Kluet er da å bruke den dataen vi har, for å trene en modell som kan klassifisere TED-talks der vi ikke vet tema.

6.0.2 Treningsdata, valideringsdata og testdata

Et viktig poeng når man jobber med maskinlæring, er at modellen kan bli veldig god på å forstå mønstre og sammenhenger innad i data. Den kan faktisk bli så god at hvis du gir den en tekst, så vet den helt sikkert om denne teksten handler om politikk eller ikke. Men det at modellen er veldig god på den dataen vi bruker for å estimere modellen, betyr ikke at den kommer til å være god på data som ikke er brukt for å estimere modellen. Her er det variasjon og forskjelligheter som gjør at de sammenhengene modellen tenkte var brennsikre for sin egen data, ikke gjelder ellers. Dette problemet kaller overtilpasning (overfitting).

For å unngå at modellen overtilpasser seg, velger vi å splitte opp data og gi modellen porsjoner av datasettet å trene på.

  • Treningsdata: Den dataen modellen bruker for å estimere klassifikasjonsmodellen.
  • Valideringsdata: Den dataen modellen bruker regelmessig for å sjekke at klassifiseringene ikke er overtilpasset treningsdata.
  • Testdata: Datasettet som brukes som en endelig test på om modellen klarer å klassifisere observasjonene godt (nok).

library(tidymodels)

set.seed(930) # Denne koden gjør at R gir tilsvarende resultater hver gang man kjører samme kode (dvs. splitter på samme sted)

ted_splitt <- initial_split(ted, #  Del datasettet i to
                            prop = 0.8, # 80 prosent av data (dvs. 80 prosen av radene/talksene) skal gå inn i treningsdata, resten blir testdata
                            strata = tema) # Passer på at Y, tema, er godt representert i både treningsdatasett og testdatasett

ted_trening <- training(ted_splitt) # Lager treningsdatasett
ted_test <- testing(ted_splitt) # Lager testdatasett

ted_trening %>% head()
## # A tibble: 6 x 2
##   text                                                                     tema 
##   <chr>                                                                    <chr>
## 1 "A public, Dewey long ago observed, is constituted through discussion a~ poli~
## 2 "If you're here today -- and I'm very happy that you are -- you've all ~ poli~
## 3 "So, can we dare to be optimistic? Well, the thesis of \"The Bottom Bil~ poli~
## 4 "I want to argue to you that in fact, politics and religion, which are ~ poli~
## 5 "Suppose that two American friends are traveling together in Italy. The~ poli~
## 6 "So, indeed, I have spent my life looking into the lives of presidents ~ poli~
ted_test %>% head()
## # A tibble: 6 x 2
##   text                                                                     tema 
##   <chr>                                                                    <chr>
## 1 "My name is Joseph, a Member of Parliament in Kenya. Picture a Maasai v~ poli~
## 2 "I want to talk about the election. For the first time in the United St~ poli~
## 3 "I want to talk about the transformed media landscape, and what it mean~ poli~
## 4 "I'm going to talk about post-conflict recovery and how we might do pos~ poli~
## 5 "Take a look at this picture. It poses a very fascinating puzzle for us~ poli~
## 6 "I just want to say my name is Emmanuel Jal. And I come from a long way~ poli~
ted_folds <- vfold_cv(ted_trening, # Splitt treningsdatasettet inn i valideringsdatasett 
                      strata = tema, # Passer på at Y, tema, er godt representert i både valideringsdatasett og treningsdatasett
                      v = 5) # Splitter opp fem ganger (slik at vi får "5-fold cross validation")

ted_folds
## #  5-fold cross-validation using stratification 
## # A tibble: 5 x 2
##   splits             id   
##   <list>             <chr>
## 1 <split [3188/798]> Fold1
## 2 <split [3189/797]> Fold2
## 3 <split [3189/797]> Fold3
## 4 <split [3189/797]> Fold4
## 5 <split [3189/797]> Fold5

6.0.3 Preprosessering

Tekstene må preprosesseres før vi kan bruke en modell på dem. Her bruker jeg “textrecipes”, som er laget spesielt for å få en god arbeidsflyt når man jobber med maskinklæring på tekst. Under kan du se at vi får ut en dokument-term-matrise (document feature matrix, DFM), der radene er dokumenter (talks i vårt tilfelle), og variablene er ord. Siden vi har brukt TF-IDF, er verdiene en vektet representasjon av hvor viktig dette ordet er for det gitte dokumentet. Dette er akkurat slik vi har jobbet med før, men textrecipes pakken slenger på en pretekst foran ordet, derfor har alle ordene fortegnelsen “tfidf_text”.

library(textrecipes)
library(quanteda)
library(tm)

ted_oppskrift <- recipe(tema ~ ., data = ted_trening) %>% # Modellen jeg ønsker å kjøre - jeg vil estimere Y ved å bruke resten av dataene
  step_mutate(text = str_to_lower(text)) %>% # Setter alle til liten bokstav
  step_mutate(text = removeNumbers(text)) %>%  # Fjerner tall
  step_mutate(text = removePunctuation(text)) %>% # Fjerner punktsetting
  step_tokenize(text) %>% # Tokeniserer teksten
  step_stem(text) %>% # Lager ordstammer
  step_stopwords(text, custom_stopword_source = stopwords("en")) %>% # Fjerner stoppord
  step_tokenfilter(text, max_tokens = 1000, min_times = 2) %>% # Beholder tokens som dukker opp maks 1000 ganger, fjerner de som dukker opp mindre enn 2 ganger
  step_tfidf(text) # Vektoriserer teksten med TF-IDF

prep(ted_oppskrift) %>% # Iverksetter preprosesseringsstegene slik beskrevet i oppskriften over
  bake(new_data = NULL) %>% # Ser på hvordan oppskrifts-objektet ser ut
  head(5) %>% select(1:5) # Henter ut de fem første radene, og de fem første kolonnene
## # A tibble: 5 x 5
##   tema     tfidf_text_ tfidf_text_abil tfidf_text_abl tfidf_text_abov
##   <fct>          <dbl>           <dbl>          <dbl>           <dbl>
## 1 politikk     0               0.00197       0.00517          0      
## 2 politikk     0.00417         0             0                0      
## 3 politikk     0.0101          0             0                0.00537
## 4 politikk     0               0             0                0      
## 5 politikk     0.00370         0             0.000877         0

6.0.4 Velg modell

Det finnes mange forskjellige modeller vi kan bruke for å estimere Y. Dere har sikkert vært borti lineær regresjon - dette er én type modell. Det finnes imidlertid flere, og noen av dem er svært avanserte. Så avanserte at noen kaller dem en “black box” - du putter data inn, modellen gjør noen kompliserte matematiske beregninger, og du får klassifiseringer ut. Dette gjelder spesielt modeller som bygger på nevrale nett og dyplæring. Vi skal ikke gå mye inn i ulike modeller i dette emnet, men pensum tar for seg applikasjonen av tre ulike modeller i statsvitenskap; support vector machine (D’Orazio et al., 2016, og random forest og logistisk regresjon (Muchlinski et al., 2016).

Hvordan implementerer vi disse modellene i R? Under demonstrerer jeg for hver av modellene ved bruk av tidymodels-pakken. Men først, la meg legge inn et objekt som forteller R litt om hvordan den skal beregne disse modellene.

contrl_preds <- control_resamples(save_pred = TRUE) # Velger å lagre prediksjonene etter at modellen har kjørt

6.0.4.1 Logistisk regresjon

glmn_spec <- 
  logistic_reg(penalty = 0.001, # Setter et par argumenter for å forhinde modeller fra å overtilpasse seg
               mixture = 0.5) %>% # Dette er typisk noe man går fram og tilbake med (kalt å "tune" modellen)
  set_engine("glmnet") %>% # Logistisk modell får vi ved å spesifisere "glmnet"
  set_mode("classification") # Vi ønsker klassifisering, ikke regresjon

glm_wf <- workflow(ted_oppskrift, # Datasettet vårt etter preprosessering
                   glmn_spec) # Modellen som spesifisert over, altså logitisk

glm_rs <- fit_resamples( # Passer modellen ved å bruke testdata og valideringsdata i sekvens fem ganger
  glm_wf, # Dette objektet forteller hva som er data og hva som er modellen
  resamples = ted_folds, # Spesifiserer hva valideringsdataene er
  control = contrl_preds # Legger valgene som jeg lagret over
)

6.0.4.2 Random forest

rf_spec <-
  rand_forest(trees = 500) %>% # Spesifiserer valg for å prøve å sørge for at modellen er best mulig tilpasset data
  set_mode("classification") 

rf_wf <- workflow(ted_oppskrift, 
                  rf_spec) 

ranger_rs <- fit_resamples(
  rf_wf,
  resamples = ted_folds,
  control = contrl_preds
)

6.0.4.3 SVM

svm_spec <-
  svm_rbf() %>%
  set_engine("kernlab", scaled = FALSE) %>% # Valg for å tune modellen
  set_mode("classification") 

svm_wf <- workflow(ted_oppskrift, 
                   svm_spec)

svm_rs <- fit_resamples(
  svm_wf,
  resamples = ted_folds,
  control = contrl_preds,
)

6.0.5 Vurder hvor gode modellene er

Det finnes flere måter å vurdere hvor godt en modell har gjort det, og hvilket mål man bruker avhenger gjerne av hva man er mest opptatt av. “Accuracy” er et mål som gir oss overordnet treffsikkerhet på modellen. Den spør: “Hvor mange av alle observasjonene, klarte modellen å klassifisere riktig?” Men vi kunne også vært spesielt interessert i antall sanne positive, falske positive, også videre. Modellen kommer nemlig aldri til å kunne klassifisere alle observasjonene riktig, og vi må gjøre en vurdering på hvor vidt vi ønsker en skjevhet i den ene eller andre retningen. Tenk deg for eksempel at vi har en modell som måler risikoen for å begå en kriminell handling etter å ha vært i fengsel - er det da viktigst at modellen predikerer de som begår kriminalitet feil, eller de som ikke begår kriminalitet?

  • Accuracy: Hvor mange observasjoner klarte modellen å klassifisere riktig?
  • Recall (sensitivity): Hvor godt modellen klassifiserer sanne positive.
  • Precision: Hvor mange positive utfall som var sanne positive.
  • Specificity: Hvor mange negative utfall som var sanne negative.
  • F1: En vektet form for “accuracy”, som gir høyere vekt til falske negative og positive.

collect_metrics(glm_rs) # Accuracy til den logistiske modellen er på 95 prosent. Den klassifiserer 95 prosent av observasjonene riktig.
## # A tibble: 2 x 6
##   .metric  .estimator  mean     n std_err .config             
##   <chr>    <chr>      <dbl> <int>   <dbl> <chr>               
## 1 accuracy binary     0.950     5 0.00322 Preprocessor1_Model1
## 2 roc_auc  binary     0.877     5 0.0151  Preprocessor1_Model1
collect_metrics(ranger_rs) # Random forest klassifiserer 95,2 prosent av observasjonene riktig
## # A tibble: 2 x 6
##   .metric  .estimator  mean     n std_err .config             
##   <chr>    <chr>      <dbl> <int>   <dbl> <chr>               
## 1 accuracy binary     0.952     5 0.00174 Preprocessor1_Model1
## 2 roc_auc  binary     0.930     5 0.00715 Preprocessor1_Model1
collect_metrics(svm_rs) # SVM klassifiserer 95,9 prosent riktig.
## # A tibble: 2 x 6
##   .metric  .estimator  mean     n std_err .config             
##   <chr>    <chr>      <dbl> <int>   <dbl> <chr>               
## 1 accuracy binary     0.959     5 0.00221 Preprocessor1_Model1
## 2 roc_auc  binary     0.903     5 0.0119  Preprocessor1_Model1

Forvirringsmatriser (confusion matrix) gir oss en oversikt over hvor mange tekster som ble klassifisert riktig og hvor mange som ble klassifisert feil innenfor hver kategori. Her ser vi noe av problemet med å stole på “accuracy” alene. Random forest latet til å klassifisere flere tekster riktig enn den logistiske modellen, men den klassifiserer også alle tekstene som “annet”.

# Lager forvirringsmatriser for hver av modellene

metrikk_glm <- collect_predictions(glm_rs)
metrikk_glm %>%
  conf_mat(truth = tema, estimate = .pred_class) %>%
  autoplot(type = "heatmap")

metrikk_rf <- collect_predictions(ranger_rs) 
metrikk_rf %>%
  conf_mat(truth = tema, estimate = .pred_class) %>%
  autoplot(type = "heatmap")

metrikk_svm <- collect_predictions(svm_rs) 
metrikk_svm %>%
  conf_mat(truth = tema, estimate = .pred_class) %>%
  autoplot(type = "heatmap")

Specificity, som fokuserer på sanne og falske negative, gir oss en annen historie enn accuracy. Her gjør random forest det elendig. Fordi den ikke har klassifisert noen observasjoner til å ha tema “politikk”, blir specificity NA. Vår SVM-modell gjør det fortsatt ganske bra, med en precision på 93 prosent - mot den logistiske modellen sine 47 prosent. La oss ta med oss SVM-modellen videre.

# Undersøker recall for hver av modellene
spec(metrikk_rf, metrikk_rf$.pred_class, metrikk_rf$tema)
## # A tibble: 1 x 3
##   .metric .estimator .estimate
##   <chr>   <chr>          <dbl>
## 1 spec    binary            NA
spec(metrikk_glm, metrikk_glm$.pred_class, metrikk_glm$tema)
## # A tibble: 1 x 3
##   .metric .estimator .estimate
##   <chr>   <chr>          <dbl>
## 1 spec    binary         0.471
spec(metrikk_svm, metrikk_svm$.pred_class, metrikk_svm$tema)
## # A tibble: 1 x 3
##   .metric .estimator .estimate
##   <chr>   <chr>          <dbl>
## 1 spec    binary         0.931

Til slutt kjører vi den endelige modellen på testdatasettet for å se hvor godt modellen klassifiserer her. Testdatasettet har ikke vært i bruk før nå, vi har holdt det fullstendig utenfor hele analysen. Dette er dermed den siste sjekken av hvor godt modellen klarer å klassifisere TED-talks som den aldri har sett før.

final_fitted <- last_fit(svm_wf, ted_splitt) # Passer SVM-modellen til testdatasettet

collect_metrics(final_fitted) # Sjekker hvor bra modellen gjorde det (accuracy og ROC-kurve)
## # A tibble: 2 x 4
##   .metric  .estimator .estimate .config             
##   <chr>    <chr>          <dbl> <chr>               
## 1 accuracy binary         0.952 Preprocessor1_Model1
## 2 roc_auc  binary         0.901 Preprocessor1_Model1

6.0.6 Bruk modellen videre

Hvis modellen er god nok, kan vi nå gå videre med å applisere modellen på TED-talksene der vi ikke vet tema. Det er viktig at vi validerer modellen grundig og vurderer om den er god nok, for alle målefeil tar vi med oss videre i analysen. På den positive siden, å bruke veiledet læring for å merke dataene, gjør at vi også kan gi et mål på hvor stor vi kan anta at målefeilen er.

7 Ikke-veiledet læring

Mye data er ikke merket. Det er typisk at vi står foran en stor samling tekster, men vi vet ikke hva tekstene handler om eller hvordan vi skal gruppere dem. I dette tilfellet er ikke-veiledet maskinlæring nyttig.

Det er vanlig å bruke ikke-veiledet maskinlæring for å:

  • Gruppere tekster etter hvilke tekster som likner på hverandre (klustring)
  • Finne tekster som er spesielt annerledes fra andre tekster (anormalitetsdeteksjon)
  • Redusere antallet variabler som vi har med å gjøre (dimensjonsreduksjon)

Word-embeddings, som vi har vært gjennom overfor, er en form for ikke-veiledet læring. Her gjør vi en dimensjonsreduksjon. Et av målene med word embeddings var nemlig å gå fra et høy-dimensjonalt rom der hvert ord blir en variabel, til et lav-dimensjonalt rom der hver ord representeres på et knippe dimensjoner, ofte et sted mellom 50 og 500. Vi skal også gå gjennom temamodeller senere, hvilket er en måte å gruppere (klustre) tekster. Likhetsanalyser, som vi også skal gjennom, er også gjerne ikke-veiledete.

Siden vi skal gjennom såpass mange modeller senere, går vi ikke like grundig inn i materien på ikke-veiledet læring her, som over. Imidlertid er det bare å bla til kapitlene om word-embeddings, likhetsanalyser og temamodellering for å se eksempler på ikke-veiledet læring på tekstdata.

7.0.1 Hierarkisk klustring

Som en innføring, er det under et eksempel på ikke-veiledet læring ved bruk av hierarkisk klustring. Her prøver modellen å organisere tekstene i grupper som hører sammen i et tre.

library(tidytext)
library(quanteda)
library(rainette)

tedtalks_tfidf <- tedtalks %>%
  group_by(id) %>%
  unnest_tokens(input = transcript,
                output = token,
                strip_punct = TRUE,
                strip_numeric = TRUE,
                token = "words") %>%
  filter(!token %in% stopwords("en")) %>%
  count(token) %>% 
  bind_tf_idf(token, id, n) %>%
  na.omit()

tedtalks_dfm <- tedtalks_tfidf %>%
  cast_dfm(id, token, tf_idf) %>%
  dfm_trim(min_termfreq = 2, max_termfreq = 1000)

rainette_cluster <- rainette(
  tedtalks_dfm,
  k = 8)
rainette_plot(rainette_cluster, tedtalks_dfm, k = 6)

8 Ordbøker

Ordbøker er en form for regelbasert estimering (estimere vil si å bruke tilgjengelig informasjon for å gjøre slutninger). Ordbøker bruker ordfrekvens for å si noe om tekstene. Reglene går da ut på å finne ut hvilke ord som gir uttrykk for et fenomen, telle opp disse (altså finne frekvensen), og dermed oppdage hvilke tekster som bruker ordene mest. Disse tekstene kan da antas å gi mest uttrykk for fenomenet.

Ordbøker er vanlig å bruke i sentimentanalyse. Fenomenet man da forsøker å måle er sentiment, for eksempel hvor positiv, negativ eller nøytral en tekst er. Vi kan anta at ord som “glad”, “best” og “fantastisk” gir uttrykk for positivitet, og “dårlig”, “elendig” og “forferdelig” gir uttrykk for negativitet. Utfra dette kan vi konstruere en ordbok.

ordbok <- tibble(positiv = c("glad", "beste", "fantastisk"), # Lag en dataframe (også kalt 'tibble') med variabler positiv og negativ
                 negativ = c("dårlig", "elendig", "forferdelig")) # Legg inn som verdier ordene som regnes som positive og negative

ordbok
## # A tibble: 3 x 2
##   positiv    negativ    
##   <chr>      <chr>      
## 1 glad       dårlig     
## 2 beste      elendig    
## 3 fantastisk forferdelig

Ta disse to tilbakemeldingene på en reise av Bennie og Bjarne:

Bennie:

Dette har vært en fantastisk ferie. Jeg blir så glad av å se på bildene, selv om de har litt dårlig kvalitet.

Bjarne:

Det er helt forferdelig at de glemte bagasjen min, i den kofferten hadde jeg mine beste bukser. For en elendig service.

Hva er deres overordnede sentiment i forhold til reisen? Vi kan anvende ordboken over for å måle forekomsten og frekvensen av negative og positive ord, og bruke disse for å si noe om deres sentiment.

library(dplyr)
library(tidytext)

# Legger tekstene til Bennie og Bjarne inn i en ny dataframe "tekster"
tekster <- tibble(person = c("Bennie", "Bjarne"),
                  tekst = c("Dette har vært en fantastisk ferie. Jeg blir så glad av å se på bildene, selv om de har litt dårlig kvalitet.",
"Det er helt forferdelig at de har glemt bagasjen min, det var der jeg hadde mine beste bukser. For en elendig service."))

tekster %>%
  unnest_tokens(input = tekst, # Hent ut bag of words ved bruk av unnest_tokens, hent ut ord fra variabelen "tekst"
                output = ord) %>% # Kall den nye variabelen "ord"
  mutate(positiv = ifelse(ord %in% ordbok$positiv, 1, 0), # Lag en ny variabel "positiv" og gi den verdi 1 hver gang variabelen "ord" har samme verdi som variabelen "positiv" i ordbok-datasettet vi lagde over
         negativ = ifelse(ord %in% ordbok$negativ, 1, 0)) %>% # Gjør samme operasjon for negative ord
  group_by(person) %>% # Grupper på person
  summarise(positiv = sum(positiv), # Legg sammen hvor mange ganger hver person hadde positive ord i teksten sin
            negativ = sum(negativ)) %>% # Og hvor mange ganger hver person hadde negative ord i teksten sin
  mutate(sentiment = positiv - negativ) # Lag en ny variabel "sentiment" der jeg trekker summen av positive ord fra summen av negative ord
## # A tibble: 2 x 4
##   person positiv negativ sentiment
##   <chr>    <dbl>   <dbl>     <dbl>
## 1 Bennie       2       1         1
## 2 Bjarne       1       2        -1

Bennie hadde to positive ord i sin tekst, “fantastisk” og “glad”. Han hadde også et negativt ord, “dårlig”. Bjarne hadde to negative ord, “forferdelig” og “elendig”, og ett positivt ord, “beste”. Legger vi sammen og trekker positivt og negativt sentiment fra hverandre, får vi overordnet sentiment. Bjarne virker å være negativ til reisen, mens Bennie er positiv til reisen.

Ordbøker kan brukes til flere ting enn sentiment. Man kan for eksempel lage ordbøker på hvor teknisk en tekst er, eller hvor miljø-orientert den er, eller liknende. Utfordringen er å lage en ordbok som i god nok grad dekker det den er ment å dekke, og å deretter validere ordboken. Det krever ofte mye lesing av tekstene for å sjekke at ordboken har klart å plukke opp alle relevante ord og aggregere riktig.

Aggregering er en annen utfordring. Aggregere vil si å gå fra individuell informasjon til overordnet informasjon - for eksempel fra ordnivå til tekstnivå. Vi vil ikke bare vite om enkelte ord er negative eller positive, men om hele teksten er negativ eller positiv. I eksempel over brukte jeg summen av positive og negative ord, og differansen mellom dem, for å finne ut hva sentiment på teksten var. I mer avanserte tilfeller vil dette sjelden fungere bra, for eksempel i tilfeller der lengden på tekstene varierer en del. Da kan det for eksempel være lurt å dele på antall ord i teksten for å få et vektet aggregat. For mer informasjon om aggregering, ta en titt på denne artikkelen.

Vi skriver med om ordbøker under sentimentanalyse, der vi tar for oss den norske sentimentordboken NorSentLex.

9 Tekststatistikk

Tekststatistikk er ulike former for mål som vi kan bruke på tekster for å kvantifisere fenomener. Dette skiller seg fra andre metoder, som å anvende en ekstern ordbok for å sjekke ordfrekvens, eller å bruke modeller på tekstene, slik vi gjør i veiledet og ikke-veiledet læring. Mens ordbøker krever at man utarbeider sin egen ordbok, og modellbaserte metoder krever at man gjør visse antakelser som kreves for å kjøre en modell, så handler tekststatistikk rett og slett om å utføre regneoperasjoner på tekstene for å si noe om dem, for eksempel hvor like eller ulike de er hverandre.

9.1 Likhet

Hvor like er to tekster hverandre? Dette kan for eksempel være interessant for å se i hvor stor grad ulike aktører snakker om samme tema på samme måte (for eksempel hvordan stater snakker om internasjonale spørsmål i felles fora). Det vanligste målet på likhet når vi jobber med tekst er cosine. Det finnes også andre mål på likhet, for eksempel korrelasjonsmål, jaccard og dice, men vi går ikke mer inn på disse her.

For å finne cosine-likhet mellom tekstene kan vi bruke funksjonen textstat_simil med argumentet method = "cosine". Denne funksjonen er en del av quanteda.textstats-pakken. Før vi kan komme dit, må vi imidlertid forbehandle (preprosessere), tokenisere og vektorisere teksten, som vanlig. For å illustrere, la oss finne cosine-likheten mellom TED-talksene som vi brukte i seksjonen om veiledet og ikke-veiledet læring.

9.1.0.1 Steg 1: Les inn data

library(tidyverse) # Pakke som brukes for veldig mange funksjoner
library(tidytext) # Pakke som brukes for veldig mange tekst-funksjoner
library(quanteda.textstats) # Pakke som har mange funksjoner for å finne tekststatistikk

tedtalks <- read_csv("./data/teds.csv") %>% # Leser inn data fra en .csv-fil
  janitor::clean_names() # Bruker funksjonen clean_names() fra janitor-pakken for å gjøre om variabelnavnene til små bokstaver med understrek

9.1.0.2 Steg 2: Forbehandle og tokenisere tekstene

Her må vi velge om vi ønsker å fjerne alle tall og symboler, hva slags enhet vi ønsker for tokens (f. eks. unigram eller bigram), om vi vil fjerne stoppord og i så fall hvilke, også videre.

tedtalks_token <- tedtalks %>%
  group_by(title) %>%
  unnest_tokens(input = transcript, # Gjør om tekstene (variabel med tekstene er kalt "transcript")
                output = token, # Gjør dem om til enkeltord kalt "token"
                token = "words", # Skal ha unigram (ett og ett ord)
                strip_punct = TRUE, # Fjern punktsetting
                strip_numeric = TRUE) %>% # Fjern tall
  filter(!token %in% quanteda::stopwords("en")) # Fjern stoppord (engelsk siden TED-talksene er på engelsk)

9.1.0.3 Steg 3: Vektorisere tekstene

Her gjør vi om tekstene til tall. I en “sekk med ord”-modell teller vi rett og slett opp hvor mange ord som forekommer innenfor hver tekst. Her regner jeg ut TF-IDF, som også vekter antallet ord etter hvor hyppig det forekommer i dokumentmassen som helhet, i et forsøk på å vekte ord som er mer toneangivende for gjeldende dokument tyngre.

tedtalks_tfidf <- tedtalks_token %>%
  count(token, name = "antall") %>% # Tell opp hvor ofte hver token dukker opp (dette gir oss sekk med ord), kaller variabelen "antall"
  bind_tf_idf(token, title, antall) # Regn om frekvensen av ord til TF-IDF - må supplere med variabel som inneholder ord (token), variabel med navn på dokument (title) og variabel med ordfrekvensen (antall)

Når tekstene er vektorisert, gjør vi dem om til en dokument-frekvens-matrise (DFM). Dette er den typen format som mange av tekststatistikk-funksjonene trenger for å kunne regne med.

tedtalks_dfm <- tedtalks_tfidf %>%
  cast_dfm(document = title, # Lag en dokument-frekevens-matrise med funksjonen cast_dfm(), fortell hva som er dokumentnavnene
           term = token, # Hva som er navnet på variabelen som inneholder tokens
           value = tf_idf) # Hva som skal bli verdien til hver observasjon på hver variabel (TF-IDF i dette tilfellet)

9.1.0.4 Steg 4: Regn ut cosine-likhet

Endelig er vi klare til å regne ut cosine-likheten mellom tekstene. Legg merke til at det er dokument-frekvens-matrisen som brukes som objekt for å regne ut likhetsmålet.

simil_tf_idf_ted <- textstat_simil(tedtalks_dfm, method = "cosine") # Funksjon for å regne ut cosine-likhet

9.1.0.5 Steg 5: Bruke funnene

La oss se litt på dataene. Hvilke tekster er mest like hverandre?

simil_tf_idf_ted %>% # Går inn i cosine-likhet objektet som ble lagd over
  as.data.frame(., diag = FALSE) %>% # Gjør om objektet til en dataframe der diagonalen er 0 (tekster likhet med seg selv er uinteressant)
  arrange(desc(cosine)) %>% # Sorter observasjonene i synkende rekkefølge etter cosine-variabelen
  slice_head(n = 20) # Hent ut bare de 20 øverste observasjonene
##                                                                   document1
## 1                           A dance in a hurricane of paper, wind and light
## 2                           A dance in a hurricane of paper, wind and light
## 3                           An 11-year-old prodigy performs old-school jazz
## 4    A memory scientist's advice on reporting harassment and discrimination
## 5                           A dance in a hurricane of paper, wind and light
## 6                           An 11-year-old prodigy performs old-school jazz
## 7                                                        Dancing with light
## 8                                        Why some anger can be good for you
## 9                                    Let's scan the whole planet with LiDAR
## 10                          A dance in a hurricane of paper, wind and light
## 11                          An 11-year-old prodigy performs old-school jazz
## 12                                                       Dancing with light
## 13                                                           My magic moves
## 14            I've lived as a man and as a woman — here's what I've learned
## 15         Let's turn the high seas into the world's largest nature reserve
## 16 Our moral imperative to act on climate change -- and 3 steps we can take
## 17                                                   Should we cry at work?
## 18                                     A future worth getting excited about
## 19                                              Can dogs sniff out malaria?
## 20                                            How many universes are there?
##                                                                                       document2
## 1                                               An 11-year-old prodigy performs old-school jazz
## 2                                                                            Dancing with light
## 3                                                                            Dancing with light
## 4                                                  How memory science can help fight harassment
## 5                                                            On violin and cello, "Passacaglia"
## 6                                                            On violin and cello, "Passacaglia"
## 7                                                            On violin and cello, "Passacaglia"
## 8                                                        Why we get mad -- and why it's healthy
## 9                                                Why we should archive everything on the planet
## 10                                                                               My magic moves
## 11                                                                               My magic moves
## 12                                                                               My magic moves
## 13                                                           On violin and cello, "Passacaglia"
## 14                                          What my gender transition taught me about womanhood
## 15                                                     Por qué necesitamos proteger el alta mar
## 16 Our moral imperative to act on climate change -- and 3 steps we can take (English voiceover)
## 17                                                      The gift and power of emotional courage
## 18                                                      The future we're building -- and boring
## 19                                                    How we're using dogs to sniff out malaria
## 20                                                        Questions no one knows the answers to
##       cosine
## 1  1.0000000
## 2  1.0000000
## 3  1.0000000
## 4  1.0000000
## 5  1.0000000
## 6  1.0000000
## 7  1.0000000
## 8  0.9947050
## 9  0.9909966
## 10 0.9788592
## 11 0.9788592
## 12 0.9788592
## 13 0.9788592
## 14 0.9583144
## 15 0.8558490
## 16 0.8510978
## 17 0.8490617
## 18 0.8128476
## 19 0.8034588
## 20 0.7885914

Noen tekster har 1 i likhet. Det betyr at de deler alle ord. Det virker litt suspekt, så la oss se nærmere på to av tekstene med 1 i cosine-likhet.

tedtalks %>% 
  filter(title %in% c("A dance in a hurricane of paper, wind and light", # Hent ut observasjonene som hadde følgende verdier på variabelen "tittel"
                      "An 11-year-old prodigy performs old-school jazz")) %>% 
  select(title, transcript) # Hent ut variabelene title og transcript (altså tittel på talen og teksten på talen)
## # A tibble: 2 x 2
##   title                                           transcript                    
##   <chr>                                           <chr>                         
## 1 A dance in a hurricane of paper, wind and light (Music) (Applause)            
## 2 An 11-year-old prodigy performs old-school jazz (Music) (Music) (Applause) (A~

Jepp, her har mye av teksten rett og slett ikke blitt transkribert. Tekstene deler “(Music)” og “(Applause)”. Vi bør nok kaste ut disse tekstene før vi gjør en eventuell analyse på TED-talksene, sammen med de andre tekstene som enten er duplikater eller har avkortet transkribering. For å fjerne observasjoner fra datasettet kunne vi for eksempel brukt koden under. Deretter måtte vi gjort steg 1, 2, 3 og 4 om igjen. Slik er det med iterative prosesser.

tedtalks <- tedtalks %>%
  filter(!title %in% c("A dance in a hurricane of paper, wind and light", # Filter med utropstegn foran betyr "ikke"
                       "An 11-year-old prodigy performs old-school jazz")) # Altså henter vi ut alle observasjoner unntatt de med følgende verdier på variabelen "title"

Hva med noen av TED-talksene som ikke hadde 1 i likhet, men latet til å score relativt likt?

tedtalks %>% 
  filter(title %in% c("Questions no one knows the answers to",
                      "How many universes are there?")) %>% 
  select(title, transcript) 
## # A tibble: 2 x 2
##   title                                 transcript                              
##   <chr>                                 <chr>                                   
## 1 Questions no one knows the answers to On a typical day at school, endless hou~
## 2 How many universes are there?         (Music) Sometimes when I'm on a long pl~

Dette ser mer riktig ut, begge disse TED-talkene tar for seg de store spørsmålene om livet, universet og evigheten. Cosine-likhet? 0,789.

9.2 Avstand

Noen ganger er vi interessert i likhet mellom tekster, andre ganger er vi mer interessert i hvor forskjellige tekstene er fra hverandre. For eksempel brukte Denny og Spirling (2018) et ulikhetsmål da de forsøkte å finne ut hvor forskjellige tekster ble etter at man hadde forbehandlet dem på forskjellige måter - i et forsøk på å finne ut hvilke forbehandlingssteg som kunne ha spesielt stor innvirkning på analysen.

Noen former for avstandsmål er:

  • Euclidean distanse
  • Manhattan distanse
  • Minkowski distanse
  • Cosine distanse (differanse mellom 1 og cosine-likhet)

For å finne de ulike avstansmålene, bruk funksjonen textstat_dist og sett avstandsmålet som argument under method, slik som vist under.

textstat_dist(tedtalks_dfm, method = "euclidean") 
textstat_dist(tedtalks_dfm, method = "manhattan") 
textstat_dist(tedtalks_dfm, method = "minkowski") 

Euclidean-avstand er det vanligste distansemålet. La oss ta en titt på hvilke TED-talks som er mest ulike hverandre ifølge euclidean-distansen.

dist_tf_idf_ted <- textstat_dist(tedtalks_dfm, method = "euclidean") # Regn ut euclidean-distanse mellom tekstene

dist_tf_idf_ted %>% 
  as.data.frame(., diag = FALSE) %>% # Lag en dataframe og sett diagnonalen til 0 (teksters ulikhet med seg selv er uinteressant)
  arrange(desc(euclidean)) %>% # Sorter observasjonene i synkende rekkefølge etter euclidean-variabelen
  slice_head(n = 10) # Hent ut de 10 øverste radene
##                                           document1
## 1                               Dance, tiny robots!
## 2                              "Iyeza" / "Zabalaza"
## 3       An electrifying acoustic guitar performance
## 4                                    "You Found Me"
## 5                         "Interpassion" / "Ba$$in"
## 6  A musical escape into a world of light and color
## 7   A dance in a hurricane of paper, wind and light
## 8   An 11-year-old prodigy performs old-school jazz
## 9                                Dancing with light
## 10         How I turned my Tourette's tics into art
##                                   document2 euclidean
## 1  How I turned my Tourette's tics into art  2.446317
## 2  How I turned my Tourette's tics into art  2.341692
## 3  How I turned my Tourette's tics into art  2.296642
## 4  How I turned my Tourette's tics into art  2.256250
## 5  How I turned my Tourette's tics into art  2.183315
## 6  How I turned my Tourette's tics into art  2.168048
## 7  How I turned my Tourette's tics into art  2.122585
## 8  How I turned my Tourette's tics into art  2.122585
## 9  How I turned my Tourette's tics into art  2.122585
## 10       On violin and cello, "Passacaglia"  2.122585

Svært mange TED-talks skiller seg mye fra talen med tittel “How I turned my Tourette’s tics into art”. Det er kanskje ikke så rart. Ser vi på denne talen kan vi se at det dukker opp mange tilfeldige ord underveis i talen (som passer med tittelen), hvilket øker ulikheten til andre taler.

tedtalks %>%
  filter(title == "How I turned my Tourette's tics into art") %>%
  select(title, transcript)
## # A tibble: 1 x 2
##   title                                    transcript                           
##   <chr>                                    <chr>                                
## 1 How I turned my Tourette's tics into art "Hello (biscuit) I'm Jess Thom, (bis~

Vi kan plotte ulikheten til talene i et varmediagram (heatmap). Siden vi har så mange observasjoner (5621 TED-talks), velger jeg å plukke ut seks tilfeldige taler til plottet.

set.seed(721) # Dette gjør at vi henter ut de samme 20 tilfeldige observasjonene hver gang vi kjører koden

dist_tf_idf_ted %>% 
  as.data.frame(., diag = FALSE) %>% # Lager om distanse-målet til en dataframe
  filter(document1 %in% c("Your body was forged in the spectacular death of stars",
                          "What coronavirus means for the global economy",
                          "What reality are you creating for yourself?",
                          "What playing Monopoly with real money taught me about my kids--and humanity",
                          "What makes the Great Wall of China so extraordinary",
                          "What it's like to be a transgender dad")) %>%
  filter(document2 %in% c("Your body was forged in the spectacular death of stars",
                          "What coronavirus means for the global economy",
                          "What reality are you creating for yourself?",
                          "What playing Monopoly with real money taught me about my kids--and humanity",
                          "What makes the Great Wall of China so extraordinary",
                          "What it's like to be a transgender dad")) %>%
  ggplot(aes(x = document1, y = document2, fill = euclidean)) + # Plotter dokumentene mot hverandre med distansemålet som verdi
  geom_tile(stat = "identity") + # geom_tile gir et varmediagram
  geom_text(aes(label = ifelse(euclidean > .001, round(euclidean, 2), ""))) + # Legger tekst inn i cellene
  scale_fill_gradient2(low = "green", mid = "white", high = "blue", midpoint = 0) + # Bestemmer fargene i cellene
  labs(x = NULL, y = NULL, color = NULL, fill = NULL) + # Vil ikke ha noe informasjon på aksene
  theme_minimal() + # Hvit plottebakgrunn
  theme(axis.text.x = element_text(angle = 30, hjust = 1, vjust = 1)) # Bestemmer vinkel og plassering på teksten på x-aksen

9.3 Lesbarhet

9.4 Uttrykk

10 Sentimentanalyse

Sentimentanalyse vil si å prøve å finne hensikt eller følelse bak en tekst. Dette kan være emosjoner, subjektivitet, eller mening. Disse klassifiserer vi gjerne under den generelle betegnelsen “sentiment”.

Sentimentanalyse er en analyseform, og ikke en metode. Isteden kan vi bruke noen av metodene vi har lært for å gjennomføre en sentimentanalyse. Det er hovedsakelig to metoder. Den ene metoden er å bruke en ordbok med ord som i ulik grad måler sentiment. Den andre metoden er å bruke veiledet læring. Den første metoden krever at man har en ordbok som inneholder mange relevante ord for å fange opp sentiment i den gitte konteksten, den andre metoden krever at man har treningsdata, altså enkelte tekster der vi allerede ved teksten sitt sentiment.

Her går vi gørst gjennom hvordan man kan gjøre en sentimentanalyse med den norske ordboken NorSentLex. Deretter viser vi hvordan man kan gjennomføre en sentimentanalyse med veiledet læring.

10.1 Sentimentanalyse på norsk med ordbok: NorSentLex

Det har lenge vært ganske lite ressurser for sentimentanalyse på norsk. Barnes et al. (2019) har ganske nylig satt sammen en stor ordbok med positive og negative ord i for både fullform og lemmatisert form med PoS-tags6. Disse ordbøkene bygger på en en oversatt og manuelt korrigert engelsk korpus av kundetilbakemeldinger (Hu and Liu 2004) og er pakket i både rå .txt-filer og .json-filer. Heldigvis har en tulling også konvertert dette til en pakke i R: NorSentLex (for øyblikket ikke på CRAN). For å laste inn/ned ordbøkene, kan du enten installere R-pakken med devtools::install_github("martigso/NorSentLex") eller bruke det du lærte i skrape-delen av denne notatboken på de originale filene. La oss illustrer med R-pakken:

# devtools::install_github("martigso/NorSentLex")

# library(NorSentLex)
# Ordbøker i fullform
names(nor_fullform_sent)
## [1] "negative" "positive"
# Ordbøker for lemma med PoS-tags
names(nor_lemma_sent)
## [1] "lemma_adj_negative"  "lemma_adj_positive"  "lemma_noun_negative"
## [4] "lemma_noun_positive" "lemma_padj_negative" "lemma_padj_positive"
## [7] "lemma_verb_negative" "lemma_verb_positive"

Hvis vi vil se på, for eksempel, noen positive ord i fullform, kan vi gå inn i listen nor_fullform_sent og listeelementet som heter $positive:

nor_fullform_sent$positive %>% head()
## [1] "absolutt"    "absolutta"   "absolutte"   "absoluttene" "absolutter" 
## [6] "absoluttet"
nor_fullform_sent$positive %>% tail()
## [1] "ønsket"        "ønskete"       "ønskt"         "ønskte"       
## [5] "øyeblikkelig"  "øyeblikkelige"
nor_fullform_sent$positive %>% sample(., 6)
## [1] "lett"       "kjæresten"  "sympatisør" "underbart"  "tilrå"     
## [6] "dufte"

Det er ikke nødvendigvis alt som gir mening som positive og negative ord, med mindre man har i bakhodet at dette er basert på kundeanmeldelser. Så vær varsom!

Om vi videre vil bruke den lemmatiserte ordboken, kan vi også trekke dette ut enkelt fra de forskjellige elementene i nor_lemma_sent. Si at vi skal bruke bare positive substantiv:

nor_lemma_sent$lemma_noun_positive %>% sample(., 6)
## [1] "skarpsinn"    "engel"        "jubilant"     "fortjeneste"  "enighet"     
## [6] "forsiktighet"

Nå når vi vet hvordan vi finner ordboken, gjenstår å lære hvordan vi bruker den. La oss bruke fullformord fra No.4-albumet data-mappen (no4.rda) som eksempel. Først splitter vi opp teksten i ord (tokens):

library(tidytext)

load("./data/no4.rda")

no4 <- no4 %>% 
  group_by(titler) %>% 
  unnest_tokens(ord, tekst)

Så kryss-refererer vi hvert ord med de positive og negative fullformordene i ordboken:

no4$pos_sent <- ifelse(no4$ord %in% nor_fullform_sent$positive, 1, 0)
no4$neg_sent <- ifelse(no4$ord %in% nor_fullform_sent$negative, 1, 0)

table(no4$pos_sent, 
      no4$neg_sent, 
      dnn = c("positiv", "negativ"))
##        negativ
## positiv    0    1
##       0 2062  117
##       1  217    1

Som vi ser, er det faktisk noen flere negative ord enn positive i albument. Men overvekten av ord er nøytrale (0 på begge). Vi kan også summere opp sentiment over sangene, og se om det er noe forskjell i sentiment mellom dem:

no4_sent <- no4 %>% 
  group_by(titler) %>% 
  summarize(pos_sent = mean(pos_sent),
            neg_sent = mean(neg_sent)) %>% 
  mutate(sent = pos_sent - neg_sent)

no4_sent
## # A tibble: 12 x 4
##    titler                                     pos_sent neg_sent     sent
##    <chr>                                         <dbl>    <dbl>    <dbl>
##  1 Alt vi ikke er                               0.100   0.0502   0.0502 
##  2 Du trenger ikke å bli stor                   0.0537  0.0604  -0.00671
##  3 En av de levende                             0.0819  0.0395   0.0424 
##  4 Feil sted                                    0.0374  0.0561  -0.0187 
##  5 Hele livet (Ft. Fredrik Høyer)               0.0421  0.0383   0.00383
##  6 Hjemme hos meg                               0.0853  0.0155   0.0698 
##  7 Hold deg fast                                0.147   0.0333   0.113  
##  8 Hvilket vi                                   0.0337  0.0506  -0.0169 
##  9 Parentes                                     0.0563  0.0423   0.0141 
## 10 Regndanse i skinnjakke (Ft. Fredrik Høyer)   0.0254  0.00847  0.0169 
## 11 Så lenge vi finnes                           0.266   0.131    0.135  
## 12 Våre beste år                                0.115   0.0513   0.0641

Ikke alverden forskjell, men noen sanger er med positive enn negative og motsatt. La oss visualisere:

no4_sent %>% 
  mutate(neg_sent = neg_sent * -1) %>% 
  ggplot(., aes(x = str_c(sprintf("%02d", 1:12),
                          ". ",
                          str_sub(titler, 1, 7),
                          "[...]"))) +
  geom_point(aes(y = neg_sent, color = "Negativ")) +
  geom_point(aes(y = pos_sent, color = "Positiv")) +
  geom_point(aes(y = sent, color = "Snitt")) +
  geom_linerange(aes(ymin = neg_sent, ymax = pos_sent), color = "gray40") +
  scale_color_manual(values = c("red", "cyan", "gray70")) +
  labs(x = NULL, y = "Sentiment", color = NULL) +
  ggdark::dark_theme_minimal() +
  theme(axis.text.x = element_text(angle = 90, vjust = .25, hjust = 0))

10.2 Sentimentanalyse med veiledet læring

Hvis vi har god treningsdata, kan det være fint å gjøre en sentimentanalyse med veiledet læring. Treningsdata vil si at vi allerede har merket data for noen av observasjonene - med andre ord vet vi allerede sentiment for enkelte tekster. Kanskje har vi satt oss ned og klassifisert dem selv (kodet tekstene til positiv, negativ eller nøytral), kanskje har vi betalt noen andre for å gjøre det, eller kanskje har noen allerede klassifisert tekstene for oss i forbindelse med en annen prosess. Du kan lese mer om dette under delen om veiledet læring.

I forelesning brukte vi et eksempel med tweeter om brexit. Disse var allerede merket som enten “positiv”, “negativ” eller “nøytral”, altså har vi treningsdata. Her viser jeg hvordan man kan gjennomføre en veiledet læring for å måle sentiment.

10.2.0.1 Steg 1: Les inn data

library(tidyverse)
library(tidymodels)
library(tm)

brexit <- readRDS("../data/brexit.rds") # Leser inn datasettet

10.2.0.2 Steg 2: Splitt opp i treningssett, valideringssett og testsett

For å ikke overtilpasse modellen, må vi splitte opp data og trene på ulike biter av datasettene. Treningssettet bruker vi til å trene modellen på, valideringssettet brukes som en fasit underveis i treningen for å sjekke at modellen gjør det bra også på out-of-sample data. Siste sjekk på om modellen klassifiserer sentiment godt nok gjøres på test-settet.

Før vi splitter opp, gjør jeg litt databehandling for å få datasettet klart til analyse. Siden vi har ekstremt mange rader i datasettet, henter jeg ut 1000 tilfeldige rader til denne illustrasjonen. Vanligvis ville vi brukt all tilgjengelig data. Til egne analyser, behold alle observasjoner som er relevant å ta med. Det gjør at treningen av modellen tar lengre tid, men desto mer informasjon modellen har å gå på, dess bedre.

set.seed(201) # Sår et frø sånn at vi splitter opp på de samme radene i trening-, validering- og testsett hver gang

brexit_classify <- brexit %>% 
  filter(sentiment != "Not Rated") %>% # Fjerner kategori "Not Rated", ettersom vi ikke er interessert i å kategorisere denne
  drop_na(influencer) %>% # Fjern missingverdier fra influencer-variabelen 
  select(influencer, hit_sentence, sentiment) %>% # Hent ut variablene influener (den som skrev tweeten), hit_sentence (teksten i tweeten) og sentiment (hva slags sentiment tweeten er klassifisert til)
  rename(text = hit_sentence) %>% # Endre navn på tekst-variabelen til "text" for å gjøre preprosessering mer oversiktlig
  slice_sample(n = 1000) # Henter ut 1000 tilfeldige rader (dvs. observasjoner) for illustrasjonsformål.

brexit_classify %>%
  slice_head(n = 5) # Viser de fem første radene i datasettet 
## # A tibble: 5 x 3
##   influencer       text                                                  senti~1
##   <chr>            <chr>                                                 <chr>  
## 1 @thomasevansadur "QT @fbpe_mark: Yes. Remainers come this daft. ; @Th~ Neutral
## 2 @davidofhook     "RT @Sky48115666: Aren't we all sick of defending br~ Negati~
## 3 @william93666824 "RT @AndyTuohyDesign: @johnredwood Maybe they are ha~ Negati~
## 4 @JaneWright17    "RT @gavinesler: If the UK wants to be regarded as a~ Neutral
## 5 @mats_247        "RT @QprEver: \U0001f1ec\U0001f1e7Tobias Ellwood \U0~ Neutral
## # ... with abbreviated variable name 1: sentiment

Hvor balansert er dette datasettet? Det er en stor overvekt av tweeter som er nøytrale.

brexit_classify %>%
  janitor::tabyl(sentiment) # Ser hvor mange observasjoner som har hver sin klasse
##  sentiment   n percent
##   Negative 355   0.355
##    Neutral 564   0.564
##   Positive  81   0.081

Da er vi klare for å splitte data. Jeg bruker initial_split og setter argument strata til sentiment for at vi skal få eksempler av både positive, negative og nøytrale tweeter i trening-, validering- og test-settene.

brexit_classify_split <- initial_split(brexit_classify, # Splitt opp datasettet
                                       strata = sentiment, # Balanser etter utfallsvariabel, sentiment
                                       prop = 0.8) # 80 prosent av radene i datasettet går i treningssettet

brexit_trening <- training(brexit_classify_split) # Hent ut treningssettet fra splitten
brexit_validering <- vfold_cv(brexit_trening, v = 5) # Lag valideringssett av treningsdataene, vi lager fem valideringssett

brexit_testing <- testing(brexit_classify_split) # Hent ut testsettet fra splitten

10.2.0.3 Steg 3: Forbehandling av teksten

Tekst er rotene greier, så vi må forbehandle den før vi analyserer. Dette innebærer å fjerne irrelevante deler og støy for å sitte igjen med det som er informasjonsbærende. I takt med tidymodels-syntaksen, bruker jeg forbehandlingsmetoden som er gitt av textrecipes-pakken. Merk at denne gjør det samme som vi har gjort tidligere når vi har brukt blant annet unnest_tokens, det er bare en litt annen måte å gjøre det samme på.

library(textrecipes)

brexit_oppskrift <- recipe(sentiment ~ text + influencer, data = brexit_trening) %>% 
  # Forteller hva som er modellen - sentiment er avhengig variabel mens teksten er uavhengig variabel
  update_role(influencer, new_role = "id variable") %>% # La med influencer over, her spesifiserer jeg at dette er id-variabel slik at den ikke brukes som en uavhengig variabel i analysen
  step_text_normalization(text) %>% # Normaliserer teksten, fjerner unicode-støy
  step_mutate(text = str_to_lower(text)) %>% # Setter alle bokstaver til liten bokstav
  step_mutate(text = removeNumbers(text)) %>% # Fjerner tall
  step_mutate(text = removePunctuation(text)) %>% # Fjerner punktsetting
  step_tokenize(text) %>% # Tokeniserer teksten til unigrams
  step_stopwords(text, custom_stopword_source = stopwords("en")) %>% # Fjerner stoppord
  step_tokenfilter(text, max_tokens = tune()) %>% # Setter en tuningsvariabel som vil finne det optimale antallet tokens å fjerne for å få best mulig treffsikkerhet i modellen senere
  step_stem(text) %>% # Tar stammen av tokensene
  step_tfidf(text) # Vektoriserer ved å hente ut tf-idf 

10.2.0.4 Steg 4: Kjør modellene

Her må vi velge hvilken maskinlæringsmodell som er best å bruke. Jeg går ikke nøye inn på dette her, og velger å enkelt bruke en SVM-modell. Denne trener jeg deretter på treningssettet og valideringssettet.

brexit_modell <- svm_rbf(cost = 3, 
                         rbf_sigma = NULL) %>% # Setter noen "tuning-argumenter". Dere kan lære mer om dette i andre maskinlæringskurs.
  set_engine("kernlab") %>% # Måten SVM estimerer på
  set_mode("classification") # Vi ømsker klassifisering, ikke regresjon

# Lager en workflow-oppskrift
brexit_wf <- workflow() %>% 
  add_recipe(brexit_oppskrift) %>%
  add_model(brexit_modell)

# Tuner modellen
tune_res <- tune_grid(
  brexit_wf, # Setter inn workflow-objektet slik det ble lagd over
  resamples = brexit_validering, # Trener med valideringssettet som sjekk
  grid = 5, # Kjører over fem ganger (bør økes i egne analyser) 
  metrics = metric_set(accuracy, sens, spec), # Henter ut treffsikkerhetsmålene "accuracy", "sensitivity" og "specificity" - noen mål er bedre enn andre for å si noe om hvor god modellen er avhengig av om man er mest opptatt av sanne positive, falske positive, falske negative eller sanne negative.
  control = control_grid(verbose = FALSE, # Ikke la modellen forklare oss hva den driver med mens den kjører
                         save_pred = TRUE) # Lagre prediksjonene
)

tune_res %>%
  autoplot(type = "marginals") # Her ser vi et plott som viser noe om hvordan modellen har valgt hva som er optimalt antall tokens gitt accuracy, sensitivity og specificity. 

Det ser ut til at høyest treffsikkerhet får vi når man beholder omtrent 1000 tokens.

collect_metrics(tune_res) %>% 
  arrange(desc(mean))
## # A tibble: 15 x 7
##    max_tokens .metric  .estimator  mean     n std_err .config             
##         <int> <chr>    <chr>      <dbl> <int>   <dbl> <chr>               
##  1        981 spec     macro      0.738     5 0.00777 Preprocessor1_Model1
##  2        452 spec     macro      0.731     5 0.00701 Preprocessor5_Model1
##  3        616 spec     macro      0.725     5 0.00576 Preprocessor3_Model1
##  4        272 spec     macro      0.721     5 0.0115  Preprocessor4_Model1
##  5         53 spec     macro      0.688     5 0.0105  Preprocessor2_Model1
##  6        981 accuracy multiclass 0.605     5 0.0133  Preprocessor1_Model1
##  7        452 accuracy multiclass 0.6       5 0.0144  Preprocessor5_Model1
##  8        616 accuracy multiclass 0.592     5 0.00871 Preprocessor3_Model1
##  9        272 accuracy multiclass 0.581     5 0.0165  Preprocessor4_Model1
## 10         53 accuracy multiclass 0.551     5 0.0147  Preprocessor2_Model1
## 11        981 sens     macro      0.455     5 0.0143  Preprocessor1_Model1
## 12        452 sens     macro      0.444     5 0.0109  Preprocessor5_Model1
## 13        616 sens     macro      0.441     5 0.0137  Preprocessor3_Model1
## 14        272 sens     macro      0.404     5 0.0162  Preprocessor4_Model1
## 15         53 sens     macro      0.375     5 0.0191  Preprocessor2_Model1

Da velger jeg like gjerne modellen med best accuracy, den som hadde beholdt 981 tokens.

best_accuracy <- select_best(tune_res, "accuracy")

10.2.0.5 Steg 5: Vurder hvor god modellen er

Har modellen blitt trent godt nok til å kunne bruke på annen data? La oss teste med en forvirringsmatrise.

final_wf <- finalize_workflow( # Ferdigstiller workflow med modellen som gir best accuracy gitt antall tokens
  brexit_wf,
  best_accuracy
)

final_res <- final_wf %>%
  last_fit(brexit_classify_split) # Kjører modellen på testsettet

final_res %>%
  collect_predictions() %>% # Samler prediksjonene fra modellen
  conf_mat(truth = sentiment,  # Plotter de sanne verdiene
           estimate = .pred_class) %>% # Opp mot de predikerte verdiene
  autoplot("heatmap") # Lager et varmediagram (heatmap) som gir oss forvirringsmatrisen

Modellen sliter med å klassifisere positivt sentiment riktig. Som vi har sett før, har den en tendens til å klassifisere en overvekt av tekstene inn i den dominerende kategorien - her “neutral”. Vi kunne kanskje gjort en større jobb med forbehandlingen av tekstene, tatt ut flere stoppord, tatt mer hensyn til emojis, og vært mer påpasselige når vi trente modellen. Kanskje ville modellen gjort det bedre om vi beholdt enda flere tokens. Hadde vi beholdt alle observasjonene, og ikke bare trent på 1000 av dem, ville modellen også hatt mer informasjon å gå på og kunne gjort det bedre. Å prøve, justere og sjekke på denne måten er en del av den iterative prosessen.

11 Temamodellering

12 Latente posisjoner

13 Noen tanker om videre læring

14 Oppsummering

Referanser

Barnes, Jeremy, Samia Touileb, Lilja Øvrelid, and Erik Velldal. 2019. “Lexicon Information in Neural Sentiment Analysis: A Multi-Task Learning Approach.” In Proceedings of the 22nd Nordic Conference on Computational Linguistics, Turku, Finland: Linköping University Electronic Press, 175–86. https://aclanthology.org/W19-6119.
Benoit, Kenneth, and Akitaka Matsuo. 2020. Spacyr: Wrapper to the ’spaCy’ ’NLP’ Library. https://CRAN.R-project.org/package=spacyr.
Blei, David M. 2012. Probabilistic Topic Models.” Communications of the ACM 55(4): 77–84.
Cooksey, Brian. 2014. “An Introduction to APIs.” Zapier, Inc. https://cdn.zapier.com/storage/learn_ebooks/e06a35cfcf092ec6dd22670383d9fd12.pdf.
D’Orazio, Vito, Steven T. Landis, Glenn Palmer, and Philip Schrodt. 2014. Separating the Wheat from the Chaff: Applications of Automated Document Classification Using Support Vector Machines.” Political Analysis 22(2): 224–42.
Denny, Matthew J., and Arthur Spirling. 2018. Text Preprocessing For Unsupervised Learning: Why It Matters, When It Misleads, And What To Do About It.” Political Analysis 26(2): 168–89. https://doi.org/10.1017/pan.2017.44.
Feldman, Ronen, and James Sanger. 2006a. Categorization.” In The Text Mining Handbook: Advanced Approaches in Analyzing Unstructured Data, Cambridge University Press, 64–81.
———. 2006b. Clustering.” In The Text Mining Handbook: Advanced Approaches in Analyzing Unstructured Data, Cambridge University Press, 82–93.
Finseraas, Henning, Bjørn Høyland, and Martin G. Søyland. 2021. “Climate Politics in Hard Times: How Local Economic Shocks Influence MPs Attention to Climate Change.” European Journal of Political Research 60(3): 738–47. https://ejpr.onlinelibrary.wiley.com/doi/abs/10.1111/1475-6765.12415.
Grimmer, Justin, Margaret E. Roberts, and Brandon M. Stewart. 2022. Text as Data: A New Framework for Machine Learning and the Social Sciences. Princeton University Press.
Høyland, Bjørn, and Martin Søyland. 2019. Electoral Reform and Parliamentary Debates.” Legislative Studies Quarterly 44(4): 593–615.
Hu, Minqing, and Bing Liu. 2004. “Mining and Summarizing Customer Reviews.” In Proceedings of the Tenth ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, KDD ’04, New York, NY, USA: Association for Computing Machinery, 168–77. https://doi.org/10.1145/1014052.1014073.
Jørgensen, Fredrik et al. 2019. “NorNE: Annotating Named Entities for Norwegian.” https://arxiv.org/abs/1911.12146.
Lauderdale, Benjamin E., and Alexander Herzog. 2016. Measuring Political Positions from Legislative Speech.” Political Analysis 24(3): 374–94.
Laver, Michael, Kenneth Benoit, and John Garry. 2003. Extracting Policy Positions from Political Texts Using Words as Data.” American Political Science Review 97(02): 311–31.
Liu, Bing. 2015. “Introduction.” In Sentiment Analysis: Mining Opinions, Sentiments, and Emotions, Cambridge University Press, 1–15. https://www.cambridge.org/core/books/sentiment-analysis/3F0F24BE12E66764ACE8F179BCDA42E9.
Lowe, Will. 2017. Understanding Wordscores.” Political Analysis 16(4): 356–71.
Lucas, Christopher et al. 2015. Computer-Assisted Text Analysis for Comparative Politics.” Political Analysis 23(2): 254–77.
Muchlinski, David, David Siroky, Jingrui He, and Matthew Kocher. 2016. Comparing Random Forest with Logistic Regression for Predicting Class-Imbalanced Civil War Onset Data.” Political Analysis 24(1): 87–103.
Pang, Bo, Lillian Lee, et al. 2008. “Opinion Mining and Sentiment Analysis.” Foundations and Trends in information retrieval 2(1–2): 1–135. https://www.cs.cornell.edu/home/llee/omsa/omsa.pdf.
Peterson, Andrew, and Arthur Spirling. 2018. Classification Accuracy as a Substantive Quantity of Interest: Measuring Polarization in Westminster Systems.” Political Analysis 26(1).
Rinker, Tyler W. 2021. textreadr: Read Text Documents into r. Buffalo, New York. https://github.com/trinker/textreadr.
Roberts, Margaret E. et al. 2014. Structural Topic Models for Open-Ended Survey Responses.” American Journal of Political Science 58(4): 1064–82.
Silge, Julia, and David Robinson. 2017. Text Mining with R: A Tidy Approach. O’Reilly Media, Inc. https://www.tidytextmining.com/.
Slapin, Jonathan B., and Sven-Oliver Proksch. 2008. A Scaling Model for Estimating Time-Series Party Positions from Texts.” American Journal of Political Science 52(3): 705–22.
Søyland, Martin. 2022. Stortingscrape: Scrape and Structure Raw Data from the Norwegian Parliament’s API. https://github.com/martigso/stortingscrape.
Stortinget. 2022. Stortingets Datatjeneste. https://data.stortinget.no.
Wickham, Hadley. 2020. Httr: Tools for Working with URLs and HTTP. https://cran.r-project.org/web/packages/httr/vignettes/quickstart.html.
Wilkerson, John, and Andreu Casas. 2017. “Large-Scale Computerized Text Analysis in Political Science: Opportunities and Challenges.” Annual Review of Political Science 20(1): 529–44. https://doi.org/10.1146/annurev-polisci-052615-025542.

  1. Vi bruker readr fordi den virker godt sammen med tidyverse og er noe raskere enn base-funksjonen read.csv()↩︎

  2. x <- stopp[[1]]↩︎

  3. tidy_books %>% filter(str_detect(word, "kitchen"))↩︎

  4. Uttrykket “koding” kan brukes på to forskjellige aktiviteter. På den ene siden snakker vi om å “kode” som å programmere - hvilket er det vi lærer i dette emnet. Å “kode” kan også bety å bygge ut et datasett. Når man ansetter noen til å merke rader i et datasett avhengig av om de faller innenfor en kategori eller ikke, kalles dette også ofte for koding (men en ganske annen form for koding).↩︎

  5. Denne regresjonslikningen er en forenkling. Hvordan sannsynligheten for at et avsnitt handler om rase estimeres, avhenger av hvilken modell vi bruker. Likningen for logistisk regresjon er mer komplisert enn denne, og det samme gjelder SVM, random forest, og andre modeller vi kan bruke for å estimere Y. Intuisjonen er imidlertid den samme - vi bruker tilgjengelig informasjon om ordenes fordeling blant dokumentene for å måle hvor vidt avsnittet handler om rase.↩︎

  6. se: https://github.com/ltgoslo/norsentlex↩︎